Most databases are older than the tool that will manage them. Yours already has tables, an index and rows that customers created. You want versioned SQL migrations from now on, but your first migration can't be "create everything": the tables are already there.
DBLift handles this with a baseline. It is Flyway-style raw-SQL migrations for Python teams: no JVM, and you see the exact SQL before it runs. A baseline tells DBLift that everything up to a given version already exists, so it starts tracking from there and runs only what comes after.
Everything below was run with DBLift 4.10.0 (open source, no licence) against PostgreSQL 17 on 4 and 6 October 2026. Terminal excerpts drop the log banner lines but are otherwise unedited.
Step 1: Start from the database you already have
The example is a small shop database. It has a customers table, an orders table and an index on orders.placed_at, all created by hand long before anyone thought about migrations.
psql -h localhost -U dblift -d shop -c "\d"
List of relations
Schema | Name | Type | Owner
--------+------------------+----------+--------
public | customers | table | dblift
public | customers_id_seq | sequence | dblift
public | orders | table | dblift
public | orders_id_seq | sequence | dblift
(4 rows)
Next to it sits a migrations folder. The first three files describe the schema as it exists today. The fourth is the first real change: a status column on orders.
V1_0_0__create_customers_table.sql
V1_1_0__create_orders_table.sql
V1_2_0__index_orders_placed_at.sql
V1_3_0__add_orders_status_column.sql
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers (id),
total_cents INTEGER NOT NULL,
placed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE orders ADD COLUMN status TEXT NOT NULL DEFAULT 'placed';
Writing the first files by hand is fine when the schema is small. For a large schema, see the Pro option further down.
Step 2: Point DBLift at it
pip install "dblift[postgresql]"
export DBLIFT_DB_URL="postgresql+psycopg://dblift:<password>@localhost:5432/shop"
If you'd rather commit the connection settings, the same thing fits in a dblift.yaml at the project root, with the password pulled from the environment:
database:
type: postgresql
host: localhost
port: 5432
database: shop
username: dblift
password: "${SHOP_DB_PASSWORD}"
migrations:
directories: [migrations]
Step 3: See what DBLift sees
Look before you touch anything. dblift info reads the history table, compares it with the files on disk, and prints one row per migration.
dblift info
=== Migration Summary ===
Total Migrations: 4
Applied Migrations: 0
Pending Migrations: 4
Failed Migrations: 0
=========================
╭────────────┬─────────┬──────────────────────────┬──────┬─────────────────────┬──────────────┬─────────┬───────────┬──────────╮
│ Category │ Version │ Description │ Type │ Installed On │ Installed By │ State │ Exec Time │ Undoable │
├────────────┼─────────┼──────────────────────────┼──────┼─────────────────────┼──────────────┼─────────┼───────────┼──────────┤
│ Versioned │ 1.0.0 │ create_customers_table │ SQL │ │ │ Pending │ │ No │
│ Versioned │ 1.1.0 │ create_orders_table │ SQL │ │ │ Pending │ │ No │
│ Versioned │ 1.2.0 │ index_orders_placed_at │ SQL │ │ │ Pending │ │ No │
│ Versioned │ 1.3.0 │ add_orders_status_column │ SQL │ │ │ Pending │ │ No │
╰────────────┴─────────┴──────────────────────────┴──────┴─────────────────────┴──────────────┴─────────┴───────────┴──────────╯
The database has no DBLift history table yet, so as far as DBLift knows nothing has run, and all four files are pending. Running dblift migrate now would start with CREATE TABLE customers on a database that already has customers. A baseline prevents that.
Step 4: Record the baseline
Pick the version that describes the schema as it stands: here 1.2.0, the index. Record it with a description a reviewer will understand a year from now.
dblift baseline --baseline-version=1.2.0 --baseline-description="Existing production schema"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ SUCCESS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Command BASELINE completed successfully (Execution time: 16 ms) ┃
┃ Schema Version: 1.2.0 ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
Baseline writes a single row to the history table (dblift_schema_history), creating the table if needed. Nothing in your schema is touched.
Choose the version deliberately. Migrations at or below the baseline will never run on this database. Set it too high and you silently skip changes the schema never received. Set it too low and the next migrate tries to create objects that already exist. Check the real schema against your files before you pick.
Step 5: Preview and apply what comes next
Always dry-run first. Only versions above the baseline are candidates now.
dblift migrate --dry-run --show-sql
Found 1 pending migration(s)
DRY RUN: Would execute the following migrations:
- V1_3_0__add_orders_status_column.sql
SQL Statements:
--------------------------------------------------------------------------------
-- V1_3_0__add_orders_status_column.sql
ALTER TABLE orders ADD COLUMN status TEXT NOT NULL DEFAULT 'placed';
--------------------------------------------------------------------------------
One migration, not four, and the exact statement it will run. Apply it. DBLift takes a migration lock so two deploys can't run at once, runs the file, and records it.
dblift migrate
Found 1 pending migration(s)
Migration lock acquired successfully
Statement executed successfully
Migration V1_3_0__add_orders_status_column.sql executed successfully in 11ms
Successfully applied migration V1_3_0__add_orders_status_column.sql
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ SUCCESS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Command MIGRATE completed successfully (Execution time: 41 ms) ┃
┃ Schema Version: 1.3.0 ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
The orders that were already there picked up the column default:
id | total_cents | status
----+-------------+--------
1 | 4200 | placed
2 | 1999 | placed
3 | 750 | placed
(3 rows)
Step 6: Check the result
dblift info
╭────────────┬─────────┬────────────────────────────┬──────┬─────────────────────┬──────────────┬────────────────┬───────────┬──────────╮
│ Category │ Version │ Description │ Type │ Installed On │ Installed By │ State │ Exec Time │ Undoable │
├────────────┼─────────┼────────────────────────────┼──────┼─────────────────────┼──────────────┼────────────────┼───────────┼──────────┤
│ Baseline │ 1.2.0 │ Existing production schema │ SQL │ 2026-10-04 19:21:42 │ dblift │ Baseline │ │ No │
│ Versioned │ 1.3.0 │ add_orders_status_column │ SQL │ 2026-10-04 19:21:44 │ dblift │ Success │ 11ms │ No │
│ Versioned │ 1.0.0 │ create_customers_table │ SQL │ │ │ Below baseline │ │ No │
│ Versioned │ 1.1.0 │ create_orders_table │ SQL │ │ │ Below baseline │ │ No │
│ Versioned │ 1.2.0 │ index_orders_placed_at │ SQL │ │ │ Below baseline │ │ No │
╰────────────┴─────────┴────────────────────────────┴──────┴─────────────────────┴──────────────┴────────────────┴───────────┴──────────╯
This is the whole story in one table: the baseline row, the new migration marked Success, and the three older files marked Below baseline. They stay in the repository as documentation of the schema, and they will never run on this database. A fresh database, such as a developer laptop or a CI service, has no baseline and runs all four in order.
validate confirms the files and the history agree:
Migration validation passed (SQL not parsed)
From here, every change is a new V file above 1.3.0, previewed with --dry-run --show-sql and applied with migrate. If you want a way back, add a matching undo file (see `dblift undo`); the rollback walkthrough uses this same shop database.
Optional (Pro): generate the baseline files instead of writing them
On a schema with hundreds of objects, writing the first files by hand is the slow part. DBLift Pro includes export-schema, which reads the live database and writes it out as a SQL migration file:
dblift export-schema --output migrations/V1__baseline.sql
If some objects are already covered by migrations, --unmanaged-only exports only the ones that aren't. You then run dblift baseline exactly as in step 4. export-schema is the only Pro command here; baseline, and everything else in this article, is open source. See Export Schema and Pricing.
Coming from Flyway? Don't baseline
If the database is already managed by Flyway, it has a flyway_schema_history table, and that history is worth keeping. dblift import-flyway copies it into DBLift's history table so each applied version keeps its identity, instead of collapsing into one baseline row. Follow Move from Flyway or the longer Flyway for Python walkthrough.
FAQ
Does dblift baseline change my schema?
No. It writes one row to the history table, creating the table on first use. Your tables, indexes and data are untouched. In the run above, the only schema change came from V1_3_0, applied by migrate afterwards.
What if I pick the wrong baseline version?
Too high, and migrations the schema never received are marked below baseline and skipped. Too low, and the next migrate tries to create objects that already exist. That's why steps 3 and 5 matter: info shows what DBLift thinks is pending, and the dry run shows the exact SQL before anything runs.
Do I baseline every environment?
Yes, every database that already has the schema. The baseline lives in each database's own history table. Empty databases, such as a new staging copy, a CI service or a laptop, don't need one; they run every migration from the start.
Read next
- Adopt an existing database: the docs guide this article follows
- `dblift baseline` reference
- Roll back a migration with undo files
- When a migration fails halfway
- Python database migrations tutorial, the five-minute SQLite version
- DBLift on GitHub