Get AI summaries of any video or article — Sign up free
Python FastAPI Tutorial (Part 15): PostgreSQL and Alembic - Database Migrations for Production thumbnail

Python FastAPI Tutorial (Part 15): PostgreSQL and Alembic - Database Migrations for Production

Corey Schafer·
5 min read

Based on Corey Schafer's video on YouTube. If you like this content, support the original creators by watching, liking and subscribing to their content.

TL;DR

SQLite plus create_all-on-startup is not a migration strategy; it can’t reliably apply schema changes like new columns to existing tables.

Briefing

The tutorial’s core shift is replacing SQLite plus “create tables on startup” with PostgreSQL plus Alembic-style migrations (via SQLAlchemy’s migration tool, Olympic). That change matters because production systems need safe, incremental schema evolution—without deleting and recreating databases or relying on startup logic that can’t detect model changes.

In development, SQLite is convenient: it’s just a local file, and the app previously used SQLAlchemy’s create_all inside the FastAPI lifespan to recreate tables when the server starts. That workflow breaks down in production. SQLite struggles with concurrent writes, create_all is not a migration system (it uses “CREATE TABLE IF NOT EXISTS,” so new columns never get added), and deleting/recreating a production database is not an option. The fix is to move to PostgreSQL—an industry-standard choice for web apps because it handles concurrent connections well—and to manage schema changes through migrations.

The setup begins by wiping the old blog.db SQLite file and installing PostgreSQL locally (Homebrew on macOS, with Docker and other OS options mentioned). A dedicated database user and password are created, then a blog database is created with that user as owner. On the Python side, the PostgreSQL driver is installed using psychopg (v3) with the binary extra, which supports both synchronous and asynchronous operations and fits SQLAlchemy’s async engine approach.

Next, configuration moves database credentials out of code. The database URL is added to Pydantic settings (loaded from an .env file) and the SQLAlchemy engine in database.py is updated to use settings.database_url, removing SQLite-specific connection arguments. With PostgreSQL running, the app no longer creates tables at startup; instead, schema management is delegated to migrations.

Olympic is installed and initialized for async SQLAlchemy. Its configuration is adjusted so it reads the database URL from settings (avoiding hardcoded credentials) and uses SQLAlchemy’s base metadata as the source of truth for autogeneration. The first migration is generated with revision --autogenerate, then applied with upgrade head. A detour highlights a common pitfall: if tables already exist (because create_all previously ran), autogenerate may produce an empty migration. Dropping the public schema and regenerating ensures the initial migration contains the expected CREATE TABLE, indexes, and foreign keys.

After the baseline schema is applied, the tutorial demonstrates incremental migration. A new likes column is added to the Post model with both a Python default and a server_default of 0, ensuring existing rows won’t fail when the new non-null column is introduced. Autogenerate detects the added column, produces a migration that adds/drops only that column, and upgrade head applies it cleanly. Verification in PostgreSQL confirms the column exists and defaults to zero.

Finally, SQLite-specific workarounds are removed—most notably a timezone fix that was needed because SQLite stored datetimes without timezone information. PostgreSQL’s timestamp with time zone support makes that workaround unnecessary. The workflow going forward is clear: generate migrations locally after model changes, review them, commit them, and apply them on production with upgrade head. For production reliability, the tutorial also flags an Olympic setting (compare_type) to catch column type length changes that autogenerate might otherwise miss.

Cornell Notes

The tutorial upgrades a FastAPI + SQLAlchemy setup from SQLite and create_all-on-startup to PostgreSQL with proper schema migrations. SQLite’s limitations (concurrent writes) and create_all’s behavior (“IF NOT EXISTS”) make it unsuitable for evolving schemas in production. PostgreSQL is installed locally, a psycopg v3 driver is added, and the database URL is moved into Pydantic settings loaded from .env. Olympic is configured for async SQLAlchemy, then used to generate and apply migrations: an initial migration creates tables, and later migrations add columns incrementally. The likes column example shows why server_default matters when adding non-null columns to tables that already contain data.

Why is create_all in the FastAPI lifespan a dead end for production schema changes?

create_all effectively runs CREATE TABLE IF NOT EXISTS for each model table. If a table already exists, it does nothing—even if the model has changed. That means adding a new field (like a likes column) won’t alter the existing table, and the only way to pick up schema changes becomes deleting and recreating the database, which is unacceptable in production.

What does switching to PostgreSQL solve compared with SQLite in this workflow?

PostgreSQL handles concurrent connections and writes better, aligning with real production traffic patterns. It also supports timezone-aware timestamps natively (timestamp with time zone), eliminating SQLite-specific hacks for datetime handling. The tutorial removes a replace(tzinfo=UTC) workaround that was needed because SQLite stripped timezone information.

How does Olympic (Alembic-style) migrations replace “delete and recreate” during development?

Olympic tracks schema changes as versioned migration files. Each migration has an upgrade() to apply changes and a downgrade() to roll them back. During development, autogenerate compares SQLAlchemy model metadata (base.metadata) against the current database state to produce incremental migrations, which can then be applied with upgrade head without wiping the database.

Why did the first autogenerate migration come out empty, and how was it fixed?

An earlier create_all run had already created the tables in the fresh PostgreSQL database. Autogenerate compares models to the existing database; since the tables already matched, it detected no differences and generated an empty upgrade. The fix was to drop and recreate the public schema (wiping tables), delete the empty migration, and regenerate so the initial migration contained the expected CREATE TABLE statements.

What’s the practical role of server_default when adding a non-null column to an existing table?

When adding a new non-null column to a table that already has rows, the database must decide what value to use for existing records. Without a server_default, the database may attempt to set NULL for existing rows, causing the migration to fail. By setting server_default=0 (and also a Python default), the generated migration includes the database-side default so existing rows get 0 automatically.

What production workflow does the tutorial recommend for migration files?

Generate migration files locally after model changes, review them, and commit them to version control. Then, on production servers, apply only with Olympic upgrade head. This avoids generating migrations directly in production and keeps schema changes reproducible and auditable.

Review Questions

  1. In what situation would Olympic autogenerate fail to detect a schema change, and what configuration option can help?
  2. Describe the difference between Python default and server_default for SQLAlchemy models when adding columns to existing tables.
  3. What commands would you use to (1) check the current migration state and (2) roll back one migration?

Key Points

  1. 1

    SQLite plus create_all-on-startup is not a migration strategy; it can’t reliably apply schema changes like new columns to existing tables.

  2. 2

    PostgreSQL is used for production readiness because it handles concurrent connections well and supports timezone-aware timestamps natively.

  3. 3

    Database credentials move into Pydantic settings loaded from .env, and the SQLAlchemy engine uses settings.database_url instead of hardcoded values.

  4. 4

    Olympic is configured for async SQLAlchemy by setting the SQLAlchemy URL programmatically and using base.metadata as target_metadata.

  5. 5

    Autogenerate migrations depend on the current database state; if tables already exist, the initial migration may be empty unless the schema is wiped first.

  6. 6

    When adding a non-null column to an existing table, server_default prevents migration failures by providing a database-side default for existing rows.

  7. 7

    Production practice is to generate and commit migrations locally, then apply them on production with upgrade head.

Highlights

create_all can silently miss model changes because it uses IF NOT EXISTS; migrations are required for incremental schema evolution.
Dropping the public schema and regenerating fixed an empty initial migration caused by tables already existing from earlier create_all runs.
Adding Post.likes required server_default=0 so existing rows wouldn’t break when introducing a new non-null column.
A SQLite timezone workaround (replace(tzinfo=UTC)) becomes unnecessary once PostgreSQL timestamp with time zone is used.

Topics

  • PostgreSQL Setup
  • Database Migrations
  • Olympic
  • SQLAlchemy Async
  • Production Workflow