Skip to content

Basics

Migrations

Migrations are versioned CFCs that carry your schema forward and back. Each one subclasses wheels.migrator.Migration, exposes an up() method that applies a change and a down() that undoes it, and lives in app/migrator/migrations/ with a timestamped filename so the runner can order them. The same migrations run in every environment — development, test, CI, production — which is how a fresh clone of your repo reaches the current schema by running one command.

You’ll learn:

  • The seven wheels migrate subcommands — four for the day-to-day workflow, three for reconciliation when something’s off
  • How to generate a migration file and what the timestamped filename means
  • The column builder methods on createTablestring, integer, datetime, references, timestamps
  • How to add and remove indexes and foreign keys without dropping a table
  • How to add, rename, and remove columns on an existing table
  • When a migration can’t be reversed and how to signal that honestly

Four subcommands cover the day-to-day workflow. Each one connects to your app’s datasource and reads the migration history out of wheels_migrator_versions, so the app has to be running for them to work — without a server, every wheels migrate subcommand refuses with a pointer at wheels start instead of guessing at a database. See Installing Wheels for the server setup.

Terminal window
wheels migrate info
  • wheels migrate latest — apply every pending migration in order. The most common command; you’ll run it after pulling someone else’s changes.
  • wheels migrate up — apply exactly one pending migration. Useful when you want to watch the effect of a single file.
  • wheels migrate down — roll back the most recently applied migration. Runs the down() method of the newest row in wheels_migrator_versions.
  • wheels migrate info — show which migrations have run and which are still pending. Read-only; safe any time. Orphan versions (rows in the tracking table with no matching file in app/migrator/migrations/) are flagged with [?] <version> <name> (applied <timestamp>) when the enriched wheels_migrator_versions.name / .applied_at columns are populated, or [?] <version> ********** NO FILE ********** (Rails-style) for legacy rows that pre-date the schema enrichment.

The runner wraps each migration in a transaction so a failing up() or down() rolls back cleanly on databases that support transactional DDL — but that support varies, so read the caution below before relying on it. One more caveat for scripts and CI: a failed migration is loud in the command output but the CLI currently still exits 0, so don’t gate a pipeline on the exit code alone (#3081).

Three additional subcommands cover the cases where the tracking table and the on-disk files have drifted — most commonly when several developers share a single dev database. Read more in Shared development databases.

  • wheels migrate doctor — single-command health report. Lists orphans, pending local migrations, and the applied count with a human-readable summary. Read-only; never mutates.
  • wheels migrate forget <version> --yes — removes a single row from wheels_migrator_versions without running down(). Use this only when a peer rolled back their migration but the tracking row stayed. Refuses if the version has a matching local file (use migrate down instead) or if the version isn’t in the table.
  • wheels migrate pretend <version> --yes — records a version as applied without running up(). Use this when a peer applied the migration via direct SQL or a different tool and you need the tracking table to reflect that. Refuses if the version is already applied or if no local file matches.

Both forget and pretend print what they would do and exit if --yes is omitted; the flag is required to mutate the tracking table.

The generator scaffolds a CFC with up() and down() stubs and drops it in app/migrator/migrations/ with a timestamp prefix.

your shell
wheels generate migration CreatePosts

That produces a file named like 20260420143000_CreatePosts.cfc — the name you typed is used verbatim. The prefix is YYYYMMDDHHMMSS — generated from your local clock at creation time — and the runner applies migrations in filename order, so the timestamp is what puts your change after everyone else’s. Never rename or reorder migration files after they’ve been committed: the history in wheels_migrator_versions keys on the timestamp prefix, and a renamed file looks like a brand-new migration to the runner.

Open the generated CFC and fill in the body. A migration for a new table typically builds the table definition with createTable(), chains column methods on the returned builder, and flushes with .create(). The down() just drops the table.

component extends="wheels.migrator.Migration" hint="Create posts table" {
function up() {
t = createTable(name="posts");
t.string(columnNames="title", allowNull=false, limit=120);
t.text(columnNames="body");
t.integer(columnNames="status", default=0);
t.datetime(columnNames="publishedAt");
t.timestamps();
t.create();
}
function down() {
dropTable(name="posts");
}
}

Three things to note:

  1. createTable("posts") returns a table-builder object. Every column method — t.string, t.integer, t.datetime — mutates that builder. Nothing hits the database until t.create().
  2. t.timestamps() creates createdAt, updatedAt, and a soft-delete column (deletedAt). Don’t also add those columns by hand — you’ll get duplicate-column errors. See Models and the ORM for how Wheels populates them automatically on save.
  3. The column-builder methods use columnNames (plural) and allowNull. Every helper also accepts columnName (singular) as an alias, but columnNames is the preferred form; the nullable flag is always allowNullnull is never accepted. A single call can create several same-typed columns at once: t.string(columnNames="firstName,lastName", limit=60).

These are the column-builder methods defined on TableDefinition. Every one takes columnNames (comma-separated string), and most accept default and allowNull. A few take type-specific extras.

MethodDB typeCommon options
t.string(columnNames=, limit=, allowNull=, default=)VARCHARlimit defaults to 255
t.char(columnNames=, limit=)CHARfixed-width string
t.text(columnNames=, size=)TEXT / MEDIUMTEXT / LONGTEXTsize is MySQL-only
t.integer(columnNames=, limit=, allowNull=, default=)INTlimit is a display width, not a size bump — for BIGINT use t.bigInteger()
t.bigInteger(columnNames=)BIGINT
t.float(columnNames=)FLOAT
t.decimal(columnNames=, precision=, scale=)DECIMALuse for money
t.boolean(columnNames=, default=)BOOLEAN / BIT
t.date(columnNames=)DATE
t.datetime(columnNames=)DATETIME / TIMESTAMP
t.time(columnNames=)TIME
t.timestamp(columnNames=)DATETIMEsingle-column version
t.binary(columnNames=)BLOB / VARBINARY
t.uniqueidentifier(columnNames=)UNIQUEIDENTIFIER / UUIDdefaults to newid()
t.references(columnNames=, foreignKey=, onDelete=)INT + FKcreates <name>id (or <name>_id, see below) and a FK constraint
t.primaryKey(columnNames=, type=, autoIncrement=)PK columntype defaults to integer; call multiple times for composite PKs (no comma-list — columnNames="a,b" is treated as a single literal column name)
t.timestamps()creates createdAt, updatedAt, deletedAtno arguments

t.references(columnNames="user") is the shortcut for the common case of a foreign key: it adds an integer column plus a FK constraint pointing at users.id. The legacy argument name referenceNames is still accepted; columnNames is the preferred modern form so the call site reads the same as every other column helper. Pass polymorphic=true to also create a type column for polymorphic associations. Pass foreignKey=false to skip the constraint and leave only the column — useful when the referenced table lives in another datasource.

The generated column suffix is controlled by the useUnderscoreReferenceColumns setting:

Settingt.references(columnNames="user") producesPolymorphic user produces
false (framework default)useriduserid, usertype
true (new-app template default)user_iduser_id, user_type

Apps generated by wheels new opt into true in config/settings.cfm so the column names match Wheels model belongsTo defaults (belongsTo("user") looks for user_id first). Existing apps with applied migrations keep the framework default of false so on-disk schemas continue to line up. Flip the setting in config/settings.cfm if you want to change the suffix the next migration produces — already-applied migrations are unaffected. Migration.cfc::addReference() and removeColumn(referenceName=) honor the same flag.

Indexes don’t belong in createTable() — they’re a separate concern, and you’ll often add them to existing tables as query patterns emerge. Use addIndex() and removeIndex().

component extends="wheels.migrator.Migration" hint="Index posts publishedAt" {
function up() {
addIndex(table="posts", columnNames="publishedAt");
addIndex(table="posts", columnNames="userId,status", indexName="idx_posts_user_status");
}
function down() {
removeIndex(table="posts", indexName="posts_publishedAt");
removeIndex(table="posts", indexName="idx_posts_user_status");
}
}

If you don’t pass indexName, Wheels generates one by joining the table name and first column — in this case posts_publishedAt. For multi-column indexes or when you want a predictable name, pass indexName explicitly. removeIndex always needs the name.

Pass unique=true on addIndex to create a unique constraint, which is what validatesUniquenessOf should sit on top of to race-proof the check at the database level.

t.references() inside createTable() adds a foreign key as part of the CREATE TABLE statement. When you need to add a FK to an existing table — or the foreign key doesn’t follow the <parent>Id convention — use addForeignKey().

component extends="wheels.migrator.Migration" hint="Add fk: comments.postId -> posts.id" {
function up() {
addForeignKey(
table="comments",
referenceTable="posts",
column="postId",
referenceColumn="id"
);
}
function down() {
dropForeignKey(table="comments", keyName="FK_comments_posts");
}
}

addForeignKey() takes four required arguments and generates a constraint name like FK_<table>_<referenceTable>. The down() drops it by that generated name — dropForeignKey(table=, keyName=). There’s also addReference() / dropReference() for the convention case where the column is <singular>Id and the table is <plural>: addReference(table="comments", referenceName="post") does the same thing as the long form above.

Three methods cover the common single-column changes. Each one is a method on the Migration base class — no table builder, no .create() flush.

Add a column:

component extends="wheels.migrator.Migration" hint="Add archived flag" {
function up() {
addColumn(table="posts", columnType="boolean", columnName="archived", default=false);
}
function down() {
removeColumn(table="posts", columnName="archived");
}
}

Rename a column:

component extends="wheels.migrator.Migration" hint="Rename body to content" {
function up() {
renameColumn(table="posts", columnName="body", newColumnName="content");
}
function down() {
renameColumn(table="posts", columnName="content", newColumnName="body");
}
}

Remove a column:

component extends="wheels.migrator.Migration" hint="Remove deprecated teaser" {
function up() {
removeColumn(table="posts", columnName="teaser");
}
function down() {
addColumn(table="posts", columnType="string", columnName="teaser", limit=255);
}
}

addColumn takes the same type-specific options as the table-builder methods: limit, precision, scale, default, allowNull. Pass columnType="string" or "integer" or "datetime" and so on — the same type names the t.* methods use internally.

Seeding via migrations — avoid, but if you must

Section titled “Seeding via migrations — avoid, but if you must”

Real seed data — users, roles, reference lookups — belongs in app/db/seeds.cfm where it runs idempotently via seedOnce(). See Seeding for that path. Don’t use migrations for seed data in long-lived projects: a INSERT in a migration runs exactly once, so editing the inserted row later means writing another migration to update it, and fresh clones of the repo end up with whatever the most recent migration wrote.

That said, you’ll occasionally want a migration to backfill data for a schema change — for example, populating a new column from an existing one. When you do, use inline SQL: execute() takes a single SQL string and nothing else — there is no parameters argument, so there’s no binding to reach for. For timestamps, use CURRENT_TIMESTAMP, which works across MySQL, PostgreSQL, SQL Server, H2, and SQLite. NOW() does not port: SQLite — the default database for wheels new apps — fails with no such function: NOW, and SQL Server has no native NOW() either. Other database-specific date functions (GETDATE(), SYSDATETIME()) don’t port at all.

component extends="wheels.migrator.Migration" hint="Seed admin role" {
function up() {
execute("INSERT INTO roles (name, createdAt, updatedAt) VALUES ('admin', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)");
}
function down() {
execute("DELETE FROM roles WHERE name = 'admin'");
}
}

Quote string values inline; wrap each statement in execute() individually. For anything structural — building a compound object, branching on current rows — consider writing a one-off script under app/scripts/ and calling it from your deploy pipeline instead.

Every up() should have a matching down() so wheels migrate down can walk the history back. Most changes are trivially reversible: createTable pairs with dropTable, addColumn with removeColumn, addIndex with removeIndex.

Some aren’t. Dropping a column with data loses that data forever; a row-level transformation (UPDATE posts SET slug = LOWER(...)) can’t be undone without the original values. In those cases, write the forward change and have down() throw — loudly — so an accidental rollback fails instead of silently corrupting the schema.

component extends="wheels.migrator.Migration" hint="Slugify posts — irreversible" {
function up() {
execute("UPDATE posts SET slug = LOWER(REPLACE(title, ' ', '-'))");
}
function down() {
Throw(
type="IrreversibleMigration",
message="Cannot reverse slug transformation — original titles may differ from the generated slugs."
);
}
}

The throw is the honest signal: “you can’t walk past this point backward.” If you need to undo the change in practice, write a new forward migration.

Generated migration files are named <YYYYMMDDHHMMSS>_<NameAsTyped>.cfc — a 14-digit timestamp from your local clock, an underscore, then the name you passed to wheels generate migration, written exactly as you typed it (no snake_casing, no added suffix). The snake_case shape you may also see in app/migrator/migrations/20260420143000_create_posts_table.cfc — comes from the model generator: wheels g model Post scaffolds a create_posts_table migration alongside the model. The timestamp determines the order the runner applies them. Don’t hand-edit the prefix; if you need to reorder after a merge conflict, regenerate the file and move your body into it.