Migrations¶
Introduction¶
Migrations are version control for your database. They let you define and share the application's schema as code. Arvel's schema DSL — Schema plus Blueprint — describes tables in Python and compiles them to Alembic operations under the hood, while a Laravel-style runner tracks which migrations have run and supports batch rollback.
Generating Migrations¶
Use the make:migration command. The framework derives the table name from the migration name and timestamps the file so migrations run in order:
Migrations are written to database/migrations/. Files are discovered and applied in lexicographic order (the timestamp prefix ensures correct ordering), and files whose names start with _ are skipped.
Migration Structure¶
A migration defines two module-level coroutines, up and down, that receive the Schema. up makes the change; down reverses it:
from arvel.database import Blueprint, Schema
async def up(schema: Schema) -> None:
def build(table: Blueprint) -> None:
table.id()
table.string("name", length=255)
table.decimal("price", precision=10, scale=2)
table.boolean("is_active").server_default("true")
table.timestamps()
Schema.create("flights", build)
async def down(schema: Schema) -> None:
Schema.drop_if_exists("flights")
Warning
Although up and down are declared async, the Schema operations inside them are synchronous — you do not await them. The runner drives the coroutine in a single step, so performing a real await inside a migration body raises RuntimeError. Keep migration bodies to schema operations only.
Running Migrations¶
arvel migrate # apply all pending migrations
arvel migrate --dry-run # print the SQL without applying it
arvel migrate:status # show applied vs pending
Rolling Back¶
Each migrate run is recorded as a "batch". Rolling back reverses the most recent batch; resetting reverses everything:
arvel migrate:rollback # undo the last batch
arvel migrate:reset # roll back every applied migration
Refreshing & Dropping¶
arvel migrate:refresh # reset, then re-run all migrations
arvel migrate:fresh # drop all tables, then re-run all migrations
Both accept --seed (run the default seeder afterward) and --seeder NAME.
Warning
migrate:fresh and migrate:refresh are destructive. They are blocked when app.env is production unless you set ARVEL_ALLOW_DESTRUCTIVE=1. Never set that against a database you care about. Note that migrate and migrate:rollback themselves have no production guard — they're considered safe forward/backward steps.
Tables¶
Creating Tables¶
Schema.create takes the table name and a callback that receives a Blueprint:
def build(table: Blueprint) -> None:
table.id()
table.string("email").unique()
table.timestamps()
Schema.create("users", build)
Updating Tables¶
Use Schema.table to alter an existing table — add columns, drop columns, add indexes:
def build(table: Blueprint) -> None:
table.string("phone").nullable()
table.drop_column("legacy_field")
Schema.table("users", build)
Dropping Tables¶
Note
drop_if_exists is currently an alias for drop — it does not yet emit IF EXISTS. Treat dropping a non-existent table as an error for now.
Columns¶
Available Column Types¶
Inside a build callback, declare columns with the Blueprint methods:
| Method | Column |
|---|---|
table.id(name="id") | Auto-increment integer primary key |
table.uuid(name) / table.ulid(name) | UUID / ULID |
table.string(name, length=255) | VARCHAR |
table.text(name) / medium_text / long_text | Text columns |
table.integer(name) / big_integer / tiny_integer | Integers |
table.boolean(name) | Boolean |
table.float(name) / table.decimal(name, precision=10, scale=2) | Numerics |
table.datetime(name, timezone=True) | Timestamp |
table.json(name) / table.jsonb(name) | JSON / JSONB |
table.binary(name) | Binary blob |
table.enum(name, values) | Enum |
table.ip_address(name) | VARCHAR(45) |
table.foreign_id(name) | Integer foreign-key column |
table.morphs(base) | Polymorphic {base}_type + {base}_id (+ index) |
table.timestamps() | created_at / updated_at |
table.soft_deletes(name="deleted_at") | nullable deleted_at (+ partial index) |
table.tsvector(name) | PostgreSQL full-text vector |
Note
A couple of methods are intentionally simplified: big_integer(...) currently emits a plain INTEGER and its unsigned flag is ignored, and there is no singular timestamp() method (use datetime(...) or timestamps()).
Column Modifiers¶
Columns are fluent — chain modifiers onto a column definition:
table.string("email", length=255).unique()
table.datetime("published_at").nullable()
table.integer("views").default(0)
table.boolean("active").server_default("true")
table.datetime("created_at").use_current()
| Modifier | Effect |
|---|---|
.nullable() | Allow NULL |
.unique() | Column-level unique constraint |
.primary() / .autoincrement() | Primary key / auto-increment |
.default(value) | Python-side default |
.server_default(value) | Database-side default |
.use_current(on_update=False) | CURRENT_TIMESTAMP default |
Note
.comment(...) and .after(...) are accepted but currently not emitted in the generated DDL — they're placeholders. Don't rely on them.
Foreign Keys¶
Declare a foreign key with foreign_id and chain constrained plus a delete rule:
table.foreign_id("user_id").constrained() # references users.id
table.foreign_id("team_id").constrained("teams").cascade_on_delete()
table.foreign_id("owner_id").nullable().null_on_delete()
constrained() infers the referenced table by pluralizing the {name}_id column. Delete-rule shorthands: .cascade_on_delete(), .null_on_delete(), .restrict_on_delete(), or the explicit .on_delete(...) / .on_update(...).
Indexes¶
table.index(["last_name", "first_name"])
table.unique("email")
table.gin_index("documents", "search_vector")
table.expression_index("LOWER(email)", name="users_lower_email")
# drop helpers
table.drop_index("users_email_unique")
table.rename_column("old_name", "new_name")
Standalone (outside a build callback): Schema.create_index(...) and Schema.create_expression_index(...).
Views & Extensions¶
The schema builder can manage raw SQL, database extensions, and views:
Schema.run_sql("UPDATE settings SET value = '1' WHERE key = 'migrated'")
Schema.install_extension("pg_trgm")
Schema.create_view("active_users", "SELECT * FROM users WHERE active")
Schema.create_materialized_view("revenue", "SELECT ...", with_data=True)
Schema.refresh_materialized_view("revenue", concurrently=True)
Async introspection helpers — has_table, has_column, get_columns, has_view — let a migration branch on the current schema state.
Database Connections¶
Connections read config/database.py — default picks the active connection and connections maps each named connection to its settings. The DB_* environment variables are the fallback when a key isn't in the file (see the cascade). Set DB_CONNECTION to choose a driver:
DB_CONNECTION | Driver |
|---|---|
postgresql / postgres | postgresql+asyncpg |
mysql / mariadb | mysql+aiomysql |
sqlite | sqlite+aiosqlite |
memory | in-memory SQLite |
DB_CONNECTION=postgresql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=myapp
DB_USERNAME=postgres
DB_PASSWORD=secret
Alternatively set a full DB_URL, which wins over the composed fields. The DatabaseServiceProvider binds the engine, session maker, and session into the container, and pings the database on boot.
Note
The database is only wired up when a connection is configured — DB_URL/DB_CONNECTION set, or a config/database.py that supplies a connection. The DatabaseServiceProvider is opt-in — add it to bootstrap/providers.py. See Service Providers.
Seeding¶
Seeders populate your database with data — useful for development and tests.
Writing Seeders¶
Generate one with arvel make:seeder, then implement the async run method:
from arvel.database import Seeder
from app.models.user import User
class DatabaseSeeder(Seeder):
async def run(self) -> None:
await User.create(name="Ada", email="[email protected]")
# run another seeder by awaiting its run()
await ProductSeeder().run()
Warning
Don't call session.commit() inside a seeder — the db:seed runner owns the transaction and commits after run() returns. Also note that self.call(other_seeder) is a composition helper that returns the seeder unchanged; it does not execute it. To run another seeder, await its run() directly.
Note
The default DatabaseSeeder refuses to run when app.env is production. Custom seeders run regardless of environment, so guard your own destructive seeders if needed.
Running Seeders¶
Factories¶
Factories generate model instances for tests and seeding, with sensible fake data. Define a Factory subclass with a definition that returns the default attributes. Generate one with arvel make:factory:
from typing import Any
from arvel.database import Factory
from app.models.user import User
class UserFactory(Factory[User]):
model = User
def definition(self) -> dict[str, Any]:
return {"name": "Ada Lovelace", "email": "[email protected]"}
Make instances (in memory) or create them (persisted):
user = await UserFactory().create()
users = await UserFactory().count(3).create()
admin = await UserFactory().state({"is_admin": True}).create()
draft = UserFactory().make() # not persisted
Build relationships, attach pivots, and run callbacks:
author = await UserFactory().has("posts", PostFactory(), count=3).create()
post = await PostFactory().for_("author", author).create()
await UserFactory().has_attached(
"roles", RoleFactory(), count=2, pivot={"assigned_by": "system"}
).create()
await UserFactory().after_creating(lambda u, faker: notify(u)).create()
Note
Faker is an optional dependency. When installed, it's passed as the second argument to after_making / after_creating callbacks. Without it, those callbacks receive None for the faker.