Перейти к содержанию

212 уроков, 15 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 18 из 20 Средний 30 мин 120 XP

PostgreSQL после SQLite: что меняется

Хорошая новость урока: код не меняется. Меняется одна строка — и всё остальное важно знать, чтобы эта строка правда оказалась единственной.

Редакция Питоники

Восемнадцать уроков каждый скрипт начинался одинаково: create_engine("sqlite:///:memory:"). Это была честная рабочая лошадка — но продакшен-приложение живёт на серверной СУБД, чаще всего на PostgreSQL. Сегодня — день переезда, и начнём он с самой приятной новости во всём курсе: почти ничего менять не придётся.

Секрет в том, что SQLAlchemy никогда не разговаривал с базой напрямую: между ними стоит диалект — переводчик, который превращает одни и те же модели и запросы в SQL конкретной СУБД. Твой код двадцать уроков писался не «на SQLite», а на языке SQLAlchemy, и у этого языка есть полный словарь и для PostgreSQL.

переезд - это одна строка
from sqlalchemy import create_engine

# разработка: файл рядом с программой
DATABASE_URL = "sqlite:///tasks.db"

# продакшен: меняется ровно одна строка
# DATABASE_URL = "postgresql+psycopg://app:secret@db.host:5432/tasks"

engine = create_engine(DATABASE_URL)
print("подключение:", engine.url)
Вывод
подключение: sqlite:///tasks.db
Строка подключенияКуда попадёшь
sqlite:///:memory:база в памяти, исчезает на выходе — все уроки курса
sqlite:///tasks.dbфайл рядом с программой
postgresql+psycopg://user:pass@host:5432/dbсервер PostgreSQL: хост, порт, логин, пароль
postgresql://user:pass@host:5432/dbто же с драйвером по умолчанию

Расшифровка серверной строки: после плюса стоит имя драйвера — psycopg, современная библиотека для связи Python с PostgreSQL (ставится командой pip install psycopg). Дальше учётка и адрес: пользователь, пароль, хост, порт, имя базы. Никакой магии — обычный адрес сервера, как у почты.

в настоящем проекте адрес берут из окружения
import os

from sqlalchemy import create_engine

engine = create_engine(os.environ["DATABASE_URL"])
Фрагмент для чтения: строка подключения содержит пароль, поэтому в коде её нет — она приходит переменной окружения или из секрета деплоя. В песочнице переменных окружения нет, а в наших примерах адрес по-прежнему sqlite — запускаемые блоки работают там, где стоят.

Пока мы на SQLite, стоит помнить, чем файловый вариант отличается от учебного :memory: из всех прошлых уроков: файл переживает запуск программы. Это и делает SQLite удобной для разработки — данные живут между перезапусками, как будут жить на PostgreSQL, только без установки сервера.

файловая база переживает запуск
import os

from sqlalchemy import create_engine, String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session

if os.path.exists("tasks.db"):
    os.remove("tasks.db")   # стартуем с чистого файла

DATABASE_URL = "sqlite:///tasks.db"   # postgresql+psycopg://... - всё остальное то же

class Base(DeclarativeBase):
    pass

class Task(Base):
    __tablename__ = "tasks"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(80))

engine = create_engine(DATABASE_URL)
Base.metadata.create_all(engine)
with Session(engine) as s:
    s.add(Task(title="переживает запуск"))
    s.commit()
engine.dispose()

engine2 = create_engine(DATABASE_URL)   # "новый запуск" - переподключились
with Session(engine2) as s:
    print([t.title for t in s.scalars(select(Task).order_by(Task.id)).all()])
Вывод
['переживает запуск']

Код не меняется — меняется база

Код моделей и запросов одинаков для любой СУБД: SQLAlchemy переводит его на диалект базы — меняется одна строка подключения. Проверим на знакомом менеджере задач: те же модели, тот же select — разница спрячется в деталях хранения, и мы её вытащим наружу сырым запросом.

тот же код - boolean глазами базы
from sqlalchemy import Boolean, create_engine, select, text, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session

DATABASE_URL = "sqlite:///:memory:"   # postgresql+psycopg://app:secret@db.host:5432/tasks
engine = create_engine(DATABASE_URL)

class Base(DeclarativeBase):
    pass

class Task(Base):
    __tablename__ = "tasks"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(80))
    done: Mapped[bool] = mapped_column(Boolean, default=False)

Base.metadata.create_all(engine)

with Session(engine) as s:
    s.add_all([Task(title="купить хлеб"), Task(title="полить цветы", done=True)])
    s.commit()

    for t in s.scalars(select(Task).order_by(Task.id)):
        print(t.title, "-", "сделано" if t.done else "не сделано")

    raw = s.execute(text("SELECT done FROM tasks WHERE id = 2")).scalar()
    print("в базе лежит:", raw)
Вывод
купить хлеб - не сделано
полить цветы - сделано
в базе лежит: 1

Python-код видит честный True и False, а SQLite хранит их как целые 1 и 0 — потому что в SQLite нет boolean-типа вообще. На PostgreSQL тот же самый код в колонке окажется настоящим true, потому что там boolean есть. Ты не менял ни строки — диалект отразил типы на возможности базы. На этой разнице построена вся таблица ниже.

Объявление в моделиPostgreSQLSQLite
Mapped[int] + primary_key=TrueINTEGER + SERIAL/IdentityINTEGER + автоинкремент
Mapped[str] + String(80)varchar(80) - длина проверяетсяTEXT - длина не проверяется
Mapped[bool] + Booleanboolean: true и falseINTEGER: 1 и 0
Mapped[datetime] + DateTimetimestampTEXT/REAL в своём формате
Numeric(10, 2)numeric - точные деньгичисло с плавающей точкой

Про первичные ключи отдельно: в PostgreSQL колонка id под управлением SQLAlchemy превращается в SERIAL или Identity — серверный счётчик, который выдаёт следующие номера сам, параллельным транзакциям и без пробелов на соревновании за очередь. mapped_column(primary_key=True) ты писал бы точно такой же; номера по-прежнему приходят в объект после flush и commit, как в уроке 9.

String(80): SQLite промолчал
from sqlalchemy import create_engine, select, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session

class Base(DeclarativeBase):
    pass

class Task(Base):
    __tablename__ = "tasks"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(80))

engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)

long_title = "с " * 100          # 200 символов - вдвое больше лимита
with Session(engine) as s:
    s.add(Task(title=long_title))
    s.commit()                    # SQLite промолчал
    back = s.scalars(select(Task)).first()
    print("символов сохранилось:", len(back.title))
Вывод
символов сохранилось: 200

Даты ведут себя приличнее, но у них своя тонкость: в PostgreSQL есть два типа времени — timestamp без зоны и timestamptz с зоной. Если колонка объявлена DateTime(timezone=True), сервер ждёт дату с часовым поясом; наивная дата из datetime.now() без tzinfo на некоторых настройках обернётся сюрпризом при чтении.

DateTime: и там и там одинаково
from datetime import datetime

from sqlalchemy import DateTime, create_engine, select, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session

class Base(DeclarativeBase):
    pass

class Task(Base):
    __tablename__ = "tasks"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(80))
    created_at: Mapped[datetime] = mapped_column(DateTime)

engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)

with Session(engine) as s:
    s.add(Task(title="отчёт", created_at=datetime(2026, 1, 15, 9, 30)))
    s.commit()
    t = s.scalars(select(Task).order_by(Task.id)).first()
    print("вернулось:", type(t.created_at).__name__, t.created_at.isoformat())
Вывод
вернулось: datetime 2026-01-15T09:30:00

Для денег есть отдельное правило: только Numeric, никаких float — двоичная плавающая точка не умеет хранить копейки. Numeric(10, 2) объявляется одинаково на любой СУБД и возвращает в код Decimal, но на PostgreSQL это настоящий серверный numeric, а на SQLite — эмуляция поверх float. Разница невидима до первой же копейки — и это ещё один аргумент гонять тесты на целевой СУБД.

Numeric: деньги как Decimal
from decimal import Decimal

from sqlalchemy import Numeric, create_engine, select, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session

class Base(DeclarativeBase):
    pass

class Item(Base):
    __tablename__ = "items"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(40))
    price: Mapped[Decimal] = mapped_column(Numeric(10, 2))

engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)

with Session(engine) as s:
    s.add(Item(name="клавиатура", price=Decimal("1999.90")))
    s.commit()
    it = s.scalars(select(Item)).first()
    print("SQLite вернул:", repr(it.price))
Вывод
SQLite вернул: Decimal('1999.90')

Главное отличие: конкурентность

Самая глубокая разница не в типах, а в устройстве. SQLite — файл: пишет в него один писатель на всю базу, остальные ждут. PostgreSQL — сервер с сотнями одновременных соединений: транзакции читают старые версии строк, пока кто-то пишет новые (механизм MVCC), и никто никого не блокирует без крайней нужды. Именно поэтому веб-продакшен живёт на серверных СУБД: десять тысяч посетителей не выстраиваются в очередь к одному файлу.

СвойствоSQLitePostgreSQL
хранениеодин файл рядом с программойсетевой сервер
записьодин писатель на базумного писателей (MVCC)
типымягкие, длина не проверяетсястрогие: varchar, boolean, numeric
доступкто открыл файл, тот и хозяинпользователи, роли, права
куда ставитсявстроена в Pythonотдельный сервер + драйвер psycopg

Хорошая новость: инструменты диагностики при переезде те же. Интроспекция из урока про Alembic работает через один и тот же интерфейс на любой СУБД — спросить у базы список таблиц и колонок можно и до переезда, и после.

интроспекция - инструменты те же
from sqlalchemy import create_engine, ForeignKey, String, inspect
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(40))
    tasks = relationship("Task", back_populates="user")

class Task(Base):
    __tablename__ = "tasks"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(80))
    user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
    user = relationship("User", back_populates="tasks")

engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)

insp = inspect(engine)
print("таблицы:", insp.get_table_names())
print("колонки tasks:", [c["name"] for c in insp.get_columns("tasks")])
Вывод
таблицы: ['tasks', 'users']
колонки tasks: ['id', 'title', 'user_id']

Стратегия переезда, которая работает: весь код держать на SQLAlchemy без сырых диалектных трюков, в разработке гонять на sqlite для скорости, а перед релизом — на настоящем PostgreSQL (в докере это одна команда), прогоняя тот же тест-сьют с другой строкой подключения. Именно для такой смены окружения тесты и пишут через переменную с адресом базы: DATABASE_URL из окружения — и сьют проверяет код там, где ему предстоит жить.

Переезд на другую СУБД — это не переписывание приложения, а смена переводчика: SQLAlchemy говорит на языке моделей, диалект переводит базе.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

from sqlalchemy import create_engine, String, select, text, Boolean
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session

class Base(DeclarativeBase):
    pass

class Task(Base):
    __tablename__ = "tasks"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(80))
    done: Mapped[bool] = mapped_column(Boolean, default=False)

engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)

with Session(engine) as s:
    s.add(Task(title="а", done=True))
    s.commit()
    raw = s.execute(text("SELECT done FROM tasks WHERE id = 1")).scalar()
    print(raw, type(raw).__name__)
from sqlalchemy import create_engine, String, inspect
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

class Base(DeclarativeBase):
    pass

class Task(Base):
    __tablename__ = "tasks"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(80))

engine = create_engine("sqlite:///board.db")
Base.metadata.create_all(engine)

print(engine.url)
print(inspect(engine).get_table_names())
Проверь себя
0 / 5

1. Что нужно изменить в коде ORM при переезде с SQLite на PostgreSQL?

2. Что означает postgresql+psycopg:// в строке подключения?

3. Как SQLite хранит Boolean-колонку?

4. Что произойдёт на PostgreSQL при вставке 200 символов в String(80)?

5. Почему продакшен живёт на серверной СУБД, а не на SQLite?

Карточки терминов
Запомнено: 0 / 6
Практика

Собери каркас переезда: строка подключения живёт в переменной DATABASE_URL вверху файла, весь остальной код строится от неё. Используй файловую базу sqlite:///board.db, создай модель Task, напечатай адрес подключения и список таблиц, которые увидела интроспекция.

practice.py
Вопросы и ответы по уроку

Чем PostgreSQL отличается от SQLite?

Устройством: SQLite — встроенная база в одном файле с одним писателем, PostgreSQL — сетевой сервер с сотнями одновременных транзакций (MVCC), ролями и строгими типами. Для разработки и учебы SQLite идеальна, под настоящей веб-нагрузкой работает серверная СУБД.

Что нужно поменять в коде SQLAlchemy для PostgreSQL?

Только строку подключения: postgresql+psycopg://user:pass@host:5432/db вместо sqlite:///... и установленный драйвер psycopg. Модели, select, сессии и relationship не меняются. В проектах адрес базы читают из переменной окружения, чтобы менять его без правки кода.

Почему SQLite не ругается на лишнюю длину строки?

Потому что в SQLite нет varchar-ограничений: колонка со String(80) хранится как TEXT без проверки длины. PostgreSQL следует схеме буквально и отклонит слишком длинное значение с DataError. Поэтому код стоит проверять на той же СУБД, что в продакшене.

Что такое SERIAL в PostgreSQL?

Серверный счётчик для первичного ключа: колонка id с Integer и primary_key=True создаётся SQLAlchemy как SERIAL или Identity-колонка, и номера выдаёт сам сервер. Для кода это незаметно — id появляется у объекта после flush и commit, как и на SQLite.

Понравился урок? Сошлитесь на него

«Код моделей и запросов одинаков для любой СУБД: SQLAlchemy переводит его на диалект базы — меняется одна строка подключения.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.