Migrations
Migrations are version control for your database. Each one is a timestamped Python file describing a change, and a migrations table records which have run, so a checkout of your project can bring any database up to the schema the code expects.
Almasix compiles schema changes through SQLAlchemy, so one blueprint means the same thing on SQLite, MySQL, MariaDB, PostgreSQL, SQL Server and Oracle — in each engine’s own words. There are no revision graphs: migrations run in filename order.
Generating migrations
Section titled “Generating migrations”smith make:migration create_flights_tablesmith make:migration add_slug_to_posts_tablesmith make:model Post -m # model + create_posts_table migrationName inference
Section titled “Name inference”When you omit --create / --table, Almasix infers the stub from the migration name:
| Name | Stub | Table |
|---|---|---|
create_users_table / create_users |
create | users |
add_description_column_to_posts_table |
update | posts |
drop_slug_from_posts_table |
update | posts |
rename_title_in_posts_table |
update | posts |
do_something_custom |
blank | — |
Prefer alter names without a leading create_: add_slug_to_posts_table. You may still pass --create widgets or --table posts to override inference.
Squashing migrations
Section titled “Squashing migrations”As an application grows, its migrations directory fills with files that only ever run in order on a fresh database. schema:dump writes the current schema — and the migration history behind it — to one file:
smith schema:dumpsmith schema:dump --prune # and delete the files it stands in forsmith schema:dump --database=pgsqlThe dump lands in database/schema/{connection}-schema.sql. A database that has never run a migration can load it instead of replaying everything:
smith migrate --schema-path=database/schema/sqlite-schema.sqlLaravel shells out to mysqldump or pg_dump for this. Almasix reads the schema back through the inspector instead, so a dump is the same shape on every engine and needs no client binary installed.
Migration structure
Section titled “Migration structure”A create migration:
from almasix.orm import Migration, Schema
class CreatePostsTable(Migration): async def up(self) -> None: await Schema.create( "posts", lambda table: ( table.id(), table.string("title"), table.timestamps(), ), )
async def down(self) -> None: await Schema.drop_if_exists("posts")An update migration uses Schema.table:
class AddSlugToPostsTable(Migration): async def up(self) -> None: await Schema.table( "posts", lambda table: (table.string("slug").nullable().unique(),), )
async def down(self) -> None: await Schema.table("posts", lambda table: table.drop_column("slug"))Files must match YYYY_MM_DD_HHMMSS_slug.py and define a Migration subclass. The class name is the StudlyCase form of the slug (create_posts_table → CreatePostsTable).
Setting the connection
Section titled “Setting the connection”A migration that belongs to another database says so:
class CreateAuditTable(Migration): connection = "audit"
async def up(self) -> None: ...Skipping a migration
Section titled “Skipping a migration”should_run decides whether the migration applies at all — a feature that only exists on some deployments, say. A migration that declines is not recorded, so it stays pending and is reconsidered next time.
class CreateSearchIndexTable(Migration): def should_run(self) -> bool: return config("scout.driver") == "database"Transactions
Section titled “Transactions”Where the engine can roll DDL back, each migration runs inside a transaction, so a failure halfway leaves nothing behind. PostgreSQL and SQL Server can; MySQL and MariaDB commit every schema statement as it runs, and SQLite’s Python driver runs CREATE TABLE outside the transactions it opens, so there only the data a migration writes comes back.
Set within_transaction = False to run one unwrapped.
Running migrations
Section titled “Running migrations”smith migratesmith migrate --seedsmith migrate --step # one batch per migrationsmith migrate --pretend # print the SQL, run none of itsmith migrate --path=database/extra,database/moresmith migrate --database=pgsqlBecause schema changes run through the connection like any other statement, --pretend prints exactly what would be sent:
Migrated: 2026_01_01_000000_create_posts_table CREATE TABLE posts (id BIGINT NOT NULL, title VARCHAR(255), PRIMARY KEY (id))Forcing migrations to run in production
Section titled “Forcing migrations to run in production”migrate, migrate:rollback and migrate:fresh refuse to run against a production database without --force, which is Laravel’s guard exactly — nothing is asked outside production. migrate:reset and migrate:refresh ask wherever they run, because they undo work everywhere.
smith migrate --force--graceful reports a failure as success, for a deploy pipeline that must not stop when the database is not reachable yet.
Rolling back
Section titled “Rolling back”smith migrate:rollback # the last batchsmith migrate:rollback --step=3 # the last three migrationssmith migrate:rollback --batch=2 # everything in batch 2smith migrate:rollback --pretendsmith migrate:reset # every migration, newest firstsmith migrate:refresh --seed # roll everything back and run it againsmith migrate:fresh --seed # drop every table and start over--step counts migrations, as Laravel’s does. migrate --step is the other half of that: it gives each migration its own batch, so each can be rolled back on its own later.
migrate:fresh drops every table with foreign keys switched off, so a schema whose tables point at each other has no wrong order to be dropped in.
Migration status
Section titled “Migration status”smith migrate:statussmith migrate:status --pendingRan [1] 2026_01_01_000000_create_posts_tableRan [2] 2026_01_02_000000_create_tags_tablePending 2026_01_03_000000_add_slug_to_posts_tableTables
Section titled “Tables”Creating tables
Section titled “Creating tables”await Schema.create( "users", lambda table: ( table.id(), table.string("name"), table.string("email").unique(), table.timestamps(), ),)await Schema.create_if_not_exists("users", ...)Table options, for the engines that have them:
def build(table): table.engine("InnoDB") table.charset("utf8mb4") table.collation("utf8mb4_unicode_ci") table.comment("Everyone who has signed up") table.id()Checking for existence
Section titled “Checking for existence”await Schema.has_table("users")await Schema.has_column("users", "email")await Schema.has_columns("users", ["email", "name"])await Schema.has_index("users", ["email"])await Schema.has_index("users", "users_email_unique")await Schema.column_type("users", "email")Updating tables
Section titled “Updating tables”await Schema.table("users", lambda table: table.integer("votes").default(0))Two conditional forms save an if:
await Schema.when_table_has_column("users", "votes", lambda table: table.drop_column("votes"))await Schema.when_table_doesnt_have_column("users", "votes", lambda table: table.integer("votes"))Renaming and dropping
Section titled “Renaming and dropping”await Schema.rename("posts", "articles")await Schema.drop("posts")await Schema.drop_if_exists("posts")await Schema.drop_all_tables()Inspecting the schema
Section titled “Inspecting the schema”await Schema.table_names()await Schema.columns("users") # name, type, nullable, defaultawait Schema.get_indexes("users") # name, columns, unique, primaryawait Schema.get_foreign_keys("users")await Schema.get_views()The db:show, db:table and db:monitor commands read the same reflection from the command line.
Columns
Section titled “Columns”Available column types
Section titled “Available column types”Keys. id, increments, tiny_increments, small_increments, medium_increments, big_increments. id() is big_increments(); SQLite narrows every width to INTEGER, because that is the only one it counts up.
Strings and text. char, string, tiny_text, text, medium_text, long_text.
Numbers. tiny_integer, small_integer, medium_integer, integer, big_integer, and an unsigned_* twin of each; float, double, decimal, unsigned_decimal, boolean.
Enumerations. enum(name, values) — a check constraint everywhere but MySQL, which has the type. set(name, values) — MySQL and MariaDB only; elsewhere it is a string wide enough to hold the list.
Documents. json, jsonb (native on PostgreSQL, plain JSON elsewhere).
Dates and times. date, date_time, date_time_tz, time, time_tz, timestamp, timestamp_tz, timestamps, timestamps_tz, nullable_timestamps, soft_deletes, soft_deletes_tz, year.
Identifiers. uuid (native on PostgreSQL, VARCHAR(36) elsewhere), ulid, ip_address, mac_address, remember_token, binary.
Relationships. foreign_id, foreign_uuid, foreign_ulid, foreign_id_for(Model), morphs, nullable_morphs, uuid_morphs, ulid_morphs.
Engine-specific. vector(name, dimensions) for pgvector and MariaDB, geometry and geography for spatial data, and raw_column(name, definition) for a type Almasix has no name for.
await Schema.create( "places", lambda table: ( table.id(), table.string("name"), table.enum("kind", ["cafe", "bar"]), table.geography("location", "point"), table.vector("embedding", 1536), table.jsonb("meta"), table.timestamps_tz(), ),)Column modifiers
Section titled “Column modifiers”| Modifier | What it does |
|---|---|
nullable() |
Allow NULL |
default(value) |
A default written into the DDL, so any writer gets it |
unsigned() |
No negatives (MySQL / MariaDB) |
unique() / index() / primary() |
Index the column |
comment(text) |
Describe the column (MySQL / MariaDB) |
charset() / collation() |
Character set and collation (MySQL / MariaDB) |
first() / after(col) / before(col) |
Where the column goes (MySQL / MariaDB) |
invisible() |
Hide from SELECT * (MySQL / MariaDB) |
use_current() |
Default to the moment the row is written |
use_current_on_update() |
Touch on every update (MySQL / MariaDB) |
virtual_as(expr) / stored_as(expr) |
A computed column |
generated_as() / always() |
An identity column |
auto_increment() / start_from(n) |
Count up, from a given number |
table.string("status").default("draft").comment("Editorial state")table.timestamp("created_at").use_current()table.string("full_name").stored_as("first_name || ' ' || last_name")Modifying columns
Section titled “Modifying columns”change() restates a column. Everything it says is applied and everything it leaves out is dropped — Laravel’s rule, because the engines that take a whole column definition enforce it:
await Schema.table( "users", lambda table: table.string("name", 50).nullable(False).default("Anonymous").change(),)Renaming and dropping columns
Section titled “Renaming and dropping columns”await Schema.table("users", lambda table: table.rename_column("body", "content"))await Schema.table("users", lambda table: table.drop_column("votes", "avatar"))Convenience drops for the helpers that added several columns at once:
table.drop_morphs("taggable")table.drop_timestamps()table.drop_soft_deletes()table.drop_remember_token()Indexes
Section titled “Indexes”Creating indexes
Section titled “Creating indexes”table.string("email").unique() # on the columntable.unique("email") # on the tabletable.unique(["locale", "slug"])table.index(["published_at", "title"])table.primary(["locale", "slug"])table.index("slug", "posts_slug_lookup") # with a name of your ownIndex names default to ix_{table}_{columns} and uq_{table}_{columns}.
Renaming and dropping indexes
Section titled “Renaming and dropping indexes”table.rename_index("ix_posts_slug", "posts_slug_lookup")table.drop_index("ix_posts_slug")table.drop_unique(["slug"])table.drop_primary()Foreign key constraints
Section titled “Foreign key constraints”table.foreign_id("user_id").constrained()table.foreign_id("author_id").constrained("users")table.foreign("user_id").references("id").on("users")The action a delete or an update takes:
table.foreign_id("user_id").constrained().cascade_on_delete()cascade_on_delete, restrict_on_delete, null_on_delete, no_action_on_delete, and the same four for updates. Dropping them:
table.drop_foreign(["user_id"])table.drop_constrained_foreign_id("user_id") # the key, then the columnConstraint checking can be switched off around a block:
async with Schema.without_foreign_key_constraints(): await Schema.drop("users")
await Schema.disable_foreign_key_constraints()await Schema.enable_foreign_key_constraints()Events
Section titled “Events”The migrator announces what it is doing, so a deploy log or a dashboard can follow along:
from almasix.events import Eventfrom almasix.orm.migration import MigrationEnded, MigrationStarted, NoPendingMigrations
Event.listen(MigrationStarted, lambda event: logger.info("running %s", event.migration))Event.listen(MigrationEnded, lambda event: logger.info("%s in %.0fms", event.migration, event.elapsed))Event.listen(NoPendingMigrations, lambda event: logger.info("nothing to do"))MigrationStarted and MigrationEnded both carry the migration name and its direction (up or down); MigrationEnded adds elapsed, the milliseconds it took, which is also what the command prints.
Always implement down() so rollbacks can reverse up(). Run Smith commands from your application root so app.* imports resolve.