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, стоит помнить, чем файловый вариант отличается от учебного :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 — разница спрячется в деталях хранения, и мы её вытащим наружу сырым запросом.
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 есть. Ты не менял ни строки — диалект отразил типы на возможности базы. На этой разнице построена вся таблица ниже.
| Объявление в модели | PostgreSQL | SQLite |
|---|---|---|
| Mapped[int] + primary_key=True | INTEGER + SERIAL/Identity | INTEGER + автоинкремент |
| Mapped[str] + String(80) | varchar(80) - длина проверяется | TEXT - длина не проверяется |
| Mapped[bool] + Boolean | boolean: true и false | INTEGER: 1 и 0 |
| Mapped[datetime] + DateTime | timestamp | TEXT/REAL в своём формате |
| Numeric(10, 2) | numeric - точные деньги | число с плавающей точкой |
Про первичные ключи отдельно: в PostgreSQL колонка id под управлением SQLAlchemy превращается в SERIAL или Identity — серверный счётчик, который выдаёт следующие номера сам, параллельным транзакциям и без пробелов на соревновании за очередь. mapped_column(primary_key=True) ты писал бы точно такой же; номера по-прежнему приходят в объект после flush и commit, как в уроке 9.
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 на некоторых настройках обернётся сюрпризом при чтении.
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. Разница невидима до первой же копейки — и это ещё один аргумент гонять тесты на целевой СУБД.
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), и никто никого не блокирует без крайней нужды. Именно поэтому веб-продакшен живёт на серверных СУБД: десять тысяч посетителей не выстраиваются в очередь к одному файлу.
| Свойство | SQLite | PostgreSQL |
|---|---|---|
| хранение | один файл рядом с программой | сетевой сервер |
| запись | один писатель на базу | много писателей (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())
1. Что нужно изменить в коде ORM при переезде с SQLite на PostgreSQL?
2. Что означает postgresql+psycopg:// в строке подключения?
3. Как SQLite хранит Boolean-колонку?
4. Что произойдёт на PostgreSQL при вставке 200 символов в String(80)?
5. Почему продакшен живёт на серверной СУБД, а не на SQLite?
Собери каркас переезда: строка подключения живёт в переменной DATABASE_URL вверху файла, весь остальной код строится от неё. Используй файловую базу sqlite:///board.db, создай модель Task, напечатай адрес подключения и список таблиц, которые увидела интроспекция.
Чем 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-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
SQLAlchemy · Урок 2
Подключение к SQLite: create_engine
Строка подключения — это адрес базы. Разбираем sqlite:///:memory: против файла, ленивый движок и журнал echo, который показывает настоящий SQL.
sqlite3 · Урок 2
Типы данных SQLite и CREATE TABLE: строим схему базы
Пять типов SQLite, ограничения NOT NULL, UNIQUE и DEFAULT, PRIMARY KEY с автонумерацией и ALTER TABLE — проектируем схему таблицы товаров по-взрослому.
SQLAlchemy · Урок 17
Alembic: миграции схемы — обзор
create_all создаёт таблицы, но никогда не меняет их: когда модель ушла вперёд базы, схему переносят миграции — и Alembic делает это по версиям, как git для схемы.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
FastAPI · Урок 7
Подключаем базу данных SQLite к FastAPI
Словарь задач из урока 5 умирает при перезапуске. Ставим на его место SQLite через SQLAlchemy: движок, сессии, Depends и CRUD, который переживает uvicorn --reload.
fastapi база данныхsqlalchemy fastapi
Flask · Урок 5
База данных в Flask: SQLite и Flask-SQLAlchemy
Пятый урок курса Flask: данные переживают перезапуск сервера. Подключаем SQLite, описываем модели Flask-SQLAlchemy, проходим CRUD и собираем гостевую книгу, которая помнит всех гостей.
flask и база данных sqlalchemyflask sqlalchemy модель
SQLAlchemy · Урок 15
SQLAlchemy во Flask: паттерн Flask-SQLAlchemy
Во Flask ORM не меняется — меняется его упаковка: db.Model вместо Base, db.session вместо ручной сессии, а закрытие соединения берёт на себя фреймворк.
flask sqlalchemyflask sqlalchemy паттерн