* simplify alembic wex, col settings & comments fixes * raise correct error
16 KiB
Alembic worked example
This is a full end-to-end Alembic walkthrough for clickhouse_connect.cc_sqlalchemy.
What is Alembic?
Alembic is a database migration tool for SQLAlchemy that tracks changes in your SQLAlchemy table definitions or ORM models and generates versioned migration scripts to keep your actual database schema in sync with those definitions. This gives you a repeatable, reviewable history of schema changes. In practice, that means:
- your models describe the schema you want
- Alembic compares those models to the live database
- Alembic generates revision files
alembic upgradeandalembic downgradeapply them to your database
Alembic does have a Python API, but most users interact with Alembic through the CLI:
alembic revision --autogenerate -m "message"
alembic upgrade head
alembic downgrade -1
alembic current
alembic history
What we are going to build
We are going to work through a very simple but typical Alembic workflow. Specifically, we will work through the following:
- Create a ClickHouse database
- Define an
eventstable in a SQLAlchemy model - Run Alembic autogenerate against an empty database
- Apply the initial migration
- Insert a couple of rows
- Change the model in a bad way
- Generate and apply that migration anyway
- Realize it was wrong and roll it back
- Fix the model
- Generate and apply the corrected migration
- Insert more data
Prerequisites
You need:
- a running ClickHouse server on
localhost:8123 - Python
clickhouse-connectinstalled with thealembicextra, which pulls in compatiblesqlalchemyandalembicversions
Install however you normally manage dependencies, for example:
pip install "clickhouse-connect[alembic]"
This guide uses:
alembicfor migrationscurlfor quick ClickHouse SQL examples
Step 1: Create a project directory
mkdir alembic_demo
cd alembic_demo
Step 2: Create the ClickHouse database
When using Alembic, it's typically expected for the database itself to already exist. However, if the tables you've defined in your models don't exist in the database, Alembic will create those.
So the normal from-scratch setup is:
- database exists first
- tables do not
Create the database once:
curl -sS "http://localhost:8123/" \
--data-binary "CREATE DATABASE IF NOT EXISTS alembic_demo"
At this point:
alembic_demoexists- it has no application tables yet
It's worth noting that you can you also start using Alembic against tables that already exist and have data, but that's beyond the scope of this simple example.
Step 3: Create your initial model
Create models.py:
from sqlalchemy import Column, MetaData, Table, text
from clickhouse_connect.cc_sqlalchemy import engines, types
metadata = MetaData()
events = Table(
"events",
metadata,
Column("id", types.UInt32(), nullable=False),
Column("event_name", types.String(), nullable=False),
Column("created_at", types.DateTime64(3, "UTC"), server_default=text("now64(3)")),
engines.MergeTree(order_by="id"),
)
This is the schema you want. Nothing has been created in ClickHouse yet.
Step 4: Initialize Alembic
alembic init alembic
That creates:
alembic.inialembic/env.pyalembic/versions/
Step 5: Point Alembic at ClickHouse
Edit alembic.ini and set:
sqlalchemy.url = clickhousedb://default:@localhost:8123/alembic_demo
Step 6: Replace alembic/env.py
The env.py file is where Alembic defines how to connect to your database and compare your models to it. The default version is generic, so we have to replace it with a ClickHouse-aware version that teaches Alembic how to correctly autogenerate and run migrations for this dialect.
Therefore, replace the generated alembic/env.py with this:
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from clickhouse_connect.cc_sqlalchemy import alembic as ch_alembic
from models import metadata
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
compare_server_default=True,
include_object=ch_alembic.include_object,
dialect_name="clickhousedb",
version_table="alembic_version",
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
default_schema = connection.exec_driver_sql("SELECT currentDatabase()").scalar()
context.configure(
connection=connection,
target_metadata=target_metadata,
include_schemas=True,
include_name=ch_alembic.make_include_name(
include_schemas=frozenset({default_schema}),
default_schema=default_schema,
),
compare_server_default=True,
include_object=ch_alembic.include_object,
process_revision_directives=ch_alembic.clickhouse_writer,
version_table="alembic_version",
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
For ClickHouse specifically, this version of env.py:
clickhouse_connect.cc_sqlalchemy.alembicregisters the ClickHouse Alembic integrationmake_include_name(...)limits autogenerate to the active databaseclickhouse_writer(...)ensures generated revisions include required ClickHouse importsversion_table="alembic_version"configures a compatible version table
Step 7: Generate the initial migration
Now we are at the key point where:
- the database exists
- your model exists
- the database is empty
Run:
alembic revision --autogenerate -m "create events"
This tells Alembic to compare the live database which is empty to the target metadata, which has a new table events with defined columns and types. This causes it to generate a revision that creates the table according to the defined model.
Typical output from this command would look something like:
Detected added table 'events'
Generating .../alembic/versions/<revision>_create_events.py ... done
Step 8: Review the generated revision
Open the new file in alembic/versions/. You should see an op.create_table(...) operation for events. This review step is important as a sanity check. Even with autogenerate, it's highly recommended users still manually inspect the migration file before applying it.
Step 9: Apply the initial migration
alembic upgrade head
In ClickHouse this creates two new tables:
eventsalembic_version
Step 10: Verify the live schema
curl -sS "http://localhost:8123/?database=alembic_demo" \
--data-binary "SHOW CREATE TABLE events"
You should see something like:
CREATE TABLE alembic_demo.events
(
`id` UInt32,
`event_name` String,
`created_at` DateTime64(3, 'UTC') DEFAULT now64(3)
)
ENGINE = MergeTree
ORDER BY id
The exact formatting may differ, and ClickHouse may include default table settings such as SETTINGS index_granularity = 8192.
Step 11: Insert a few rows
Now that the table exists, let's insert some data:
curl -sS "http://localhost:8123/?database=alembic_demo" \
--data-binary "INSERT INTO events (id, event_name) FORMAT Values (101, 'signup'), (102, 'purchase')"
Verify it with:
curl -sS "http://localhost:8123/?database=alembic_demo" \
--data-binary "SELECT id, event_name, created_at FROM events ORDER BY id FORMAT Vertical"
At this point you have a normal application state. Schema exists, data exists, and Alembic knows the current revision.
Step 12: Make a bad model change
Now let's pretend you changed the model, but picked the wrong column name.
Replace models.py with:
from sqlalchemy import Column, MetaData, Table, text
from clickhouse_connect.cc_sqlalchemy import engines, types
metadata = MetaData()
events = Table(
"events",
metadata,
Column("id", types.UInt32(), nullable=False),
Column("event_name", types.String(), nullable=False),
Column("details", types.String(), nullable=True, server_default=text("'{}'")),
Column("created_at", types.DateTime64(3, "UTC"), server_default=text("now64(3)")),
engines.MergeTree(order_by="id"),
)
This is a perfectly valid schema change, but in our story it is the wrong one. We really wanted the new column to be called payload, not details.
Step 13: Generate the migration for the bad change
alembic revision --autogenerate -m "add details column"
Typical output:
Detected added column 'events.details'
Step 14: Apply it anyway
alembic upgrade head
Verify the schema:
curl -sS "http://localhost:8123/?database=alembic_demo" \
--data-binary "SHOW CREATE TABLE events"
You should now see details in the table definition.
Step 15: Realize it was wrong and roll it back
This is where Alembic becomes useful in normal development. If the last schema change was wrong, just roll back one revision:
alembic downgrade -1
Now check the table again:
curl -sS "http://localhost:8123/?database=alembic_demo" \
--data-binary "SHOW CREATE TABLE events"
The details column should be gone.
Step 16: Remove the bad local revision file
For a local mistake that has not been shared yet, the normal next step is to remove the bad revision file you just created.
List the revision files:
ls alembic/versions
Then remove the bad one:
rm alembic/versions/<bad_revision>_add_details_column.py
After downgrade -1, your database is back on the previous revision but Alembic's script history still has the bad revision file as the current head. If you leave that file in place, alembic revision --autogenerate will tell you the target database is not up to date. Generally, if the bad revision was only local, removing it is fine. However, if the bad revision has already been shared or applied elsewhere, do not delete history. Instead, create a new corrective revision.
Step 17: Fix the model
Now correct models.py so it reflects the schema you actually want:
from sqlalchemy import Column, MetaData, Table, text
from clickhouse_connect.cc_sqlalchemy import engines, types
metadata = MetaData()
events = Table(
"events",
metadata,
Column("id", types.UInt32(), nullable=False),
Column("event_name", types.String(), nullable=False, comment="Human-readable event name"),
Column("payload", types.String(), nullable=True, server_default=text("'{}'")),
Column("created_at", types.DateTime64(3, "UTC"), server_default=text("now64(3)")),
engines.MergeTree(order_by="id"),
)
This corrected change does two things. First, it adds the payload column and second, it adds a comment to event_name. The point of the second change is strictly to show that Alembic compares the current state of the model, your "ground truth" against what's currently in the live database. So all the changes you make will be reflected.
Step 18: Generate the corrected migration
alembic revision --autogenerate -m "add payload and event name comment"
Typical output:
Detected added column 'events.payload'
Detected column comment 'events.event_name'
Step 19: Apply the corrected migration
alembic upgrade head
You may also find other inspection commands useful as well.
alembic current
alembic history
Step 20: Verify the corrected schema
curl -sS "http://localhost:8123/?database=alembic_demo" \
--data-binary "SHOW CREATE TABLE events"
You should see something like:
CREATE TABLE alembic_demo.events
(
`id` UInt32,
`event_name` String COMMENT 'Human-readable event name',
`created_at` DateTime64(3, 'UTC') DEFAULT now64(3),
`payload` Nullable(String) DEFAULT '{}'
)
ENGINE = MergeTree
ORDER BY id
The exact formatting may differ, and ClickHouse may include default table settings such as SETTINGS index_granularity = 8192.
Step 21: Insert data using the new schema
Now insert rows that use the new column:
curl -sS "http://localhost:8123/?database=alembic_demo" \
--data-binary "INSERT INTO events (id, event_name, payload) FORMAT Values (103, 'refund', '{\"amount\": 12}'), (104, 'renewal', '{\"plan\": \"pro\"}')"
Query the table:
curl -sS "http://localhost:8123/?database=alembic_demo" \
--data-binary "SELECT id, event_name, payload FROM events ORDER BY id FORMAT Vertical"
You should see the original rows still exist and the new rows use the new payload column.
What A Typical Alembic Loop Looks Like
After initial setup, the day-to-day loop is usually relatively simple:
- Edit your models
- Run
alembic revision --autogenerate -m "describe change" - Review the generated migration file
- Run
alembic upgrade head - If needed, run
alembic downgrade -1and try again
That is the expected Alembic workflow, and it is how this ClickHouse integration is intended to be used.
Optional: Manual migration operations
You do not need this section for the main walkthrough. Autogenerate handles the common cases shown above, but you can also write migration operations by hand when you want more control over a specific change.
The examples below use the same alembic_demo.events table from the walkthrough.
Add a column with placement and operation-level settings:
from alembic import op
from sqlalchemy import Column, text
from clickhouse_connect.cc_sqlalchemy import types
op.add_column(
"events",
Column(
"payload",
types.String(),
server_default=text("'{}'"),
clickhouse_after="id",
),
schema="alembic_demo",
if_not_exists=True,
clickhouse_settings={"alter_sync": 2},
)
Alter a column default:
op.alter_column(
"events",
"payload",
schema="alembic_demo",
existing_type=types.Nullable(types.String()),
server_default=text("'[]'"),
clickhouse_settings={"alter_sync": 2},
)
Drop a column safely:
op.drop_column(
"events",
"payload",
schema="alembic_demo",
if_exists=True,
)
For DDL that Alembic does not model, use op.execute(...) directly with a raw SQL string.
What This Example Covered
This walkthrough focused on the parts of the integration most users need first:
- creating a ClickHouse table from SQLAlchemy metadata
- preserving the ClickHouse table engine in generated migrations
- adding and dropping columns with autogenerate
- changing a column default and comment
- applying and rolling back revisions with the Alembic CLI
- inserting and querying data before and after schema changes
- using optional manual column operations when autogenerate is not enough
Migrating from clickhouse_sqlalchemy
If you are coming from clickhouse_sqlalchemy, see MIGRATING_FROM_CLICKHOUSE_SQLALCHEMY.md for the import and URL changes you need.
Summary
The intended user experience is:
- create the database once
- write models first
- autogenerate the initial migration from an empty database
- apply migrations with the Alembic CLI
- evolve the models over time
- roll back bad revisions when needed
If you find yourself needing features or hitting bugs, find us on GitHub.