DBML Tutorial: Database Schema as Code
Chat2DB TeamDatabase diagrams have a persistent problem: they go stale. Someone draws a beautiful ERD in a diagramming tool, it is accurate for about three weeks, and then it becomes actively misleading — worse than no diagram at all, because people trust it.
DBML (Database Markup Language) attacks this by making the diagram a text file. It is a small, readable language for describing schemas that lives in your repository, goes through code review alongside the migration that changes it, and renders to a diagram on demand. The diagram is generated from the source of truth rather than maintained alongside it.
It was created for dbdiagram.io (opens in a new tab) and is open source under Apache 2.0.
The basics
A DBML file is mostly Table blocks and Ref lines.
Table users {
id integer [pk, increment]
username varchar(50) [not null, unique]
email varchar(255) [not null, unique]
created_at timestamptz [not null, default: `now()`]
}Each column is name type [settings]. That is nearly the whole language.
Column settings:
| Setting | Meaning |
|---|---|
pk / primary key | primary key |
increment | auto-increment |
not null | NOT NULL |
null | explicitly nullable |
unique | unique constraint |
default: value | default value |
note: 'text' | column comment |
Defaults follow a small set of rules that are worth getting right:
Table products {
id integer [pk, increment]
name varchar(200) [not null]
status varchar(20) [not null, default: 'draft'] // string literal
price decimal(12,2) [not null, default: 0] // number
is_active boolean [not null, default: true] // boolean
created_at timestamptz [not null, default: `now()`] // SQL expression
deleted_at timestamptz [default: null]
}Backticks mean "this is raw SQL, pass it through". Without them, now() would be treated as the literal string 'now()' — the same trap that exists in Liquibase and most other schema-as-code tools.
Relationships
Ref lines declare foreign keys, and the operator encodes cardinality:
| Operator | Meaning |
|---|---|
> | many-to-one |
< | one-to-many |
- | one-to-one |
<> | many-to-many |
Ref: posts.user_id > users.id // many posts, one user
Ref: users.id < posts.user_id // identical, stated from the other side
Ref: users.id - user_profiles.user_id // one-to-oneThe direction determines which table gets the foreign key. posts.user_id > users.id puts the FK on posts, which is almost always what you mean.
There are three equivalent syntaxes. Inline on the column is the most compact:
Table posts {
id bigint [pk, increment]
user_id integer [not null, ref: > users.id]
title varchar(200) [not null]
}Standalone after the tables keeps relationships together and is easier to scan in a large schema:
Ref: posts.user_id > users.idAnd a named block lets you specify referential actions:
Ref posts_author {
posts.user_id > users.id [delete: cascade, update: no action]
}Supported actions are cascade, restrict, set null, set default and no action.
Composite foreign keys
Ref: order_items.(order_id, line_no) > order_lines.(order_id, line_no)Many-to-many
DBML lets you write <> directly, and dbdiagram renders it, but no real database has a native many-to-many constraint. Be explicit about the join table instead — it is what you will actually create:
Table posts_tags {
post_id bigint [not null]
tag_id integer [not null]
indexes {
(post_id, tag_id) [pk]
}
}
Ref: posts_tags.post_id > posts.id [delete: cascade]
Ref: posts_tags.tag_id > tags.id [delete: cascade]The indexes block with [pk] is how you declare a composite primary key.
Indexes
Table orders {
id bigint [pk, increment]
customer_id bigint [not null]
status varchar(20) [not null]
placed_at timestamptz [not null]
total_amount decimal(12,2) [not null]
indexes {
(customer_id, placed_at) // composite
status // single column
(status, placed_at) [name: 'idx_status_time']
(lower(email)) [unique] // expression index
placed_at [type: brin] // index method
}
}Index settings are unique, pk, name and type (btree, hash, gin, gist, brin).
Enums
Enum order_status {
pending
paid
shipped
cancelled [note: 'set by support or by timeout']
}
Table orders {
id bigint [pk, increment]
status order_status [not null, default: 'pending']
}dbdiagram renders enum columns distinctly, and the enum becomes a CREATE TYPE on PostgreSQL.
Notes and documentation
Notes are where DBML earns its keep as documentation rather than just a diagram:
Table subscriptions {
id bigint [pk, increment]
customer_id bigint [not null]
plan_code varchar(40) [not null, note: 'references billing.plans in the billing service']
current_period_end timestamptz [not null, note: 'UTC; renewal job reads this']
cancelled_at timestamptz [note: 'soft cancel — access continues until period end']
Note: '''
One active subscription per customer is enforced by the application,
not by a constraint, because historical rows must be retained.
See ADR-114 for the reasoning.
'''
}Triple-quoted notes support multiple lines and Markdown. This is the kind of context that normally lives in someone's head or in a Slack thread, and putting it next to the column it describes is the most valuable thing DBML does.
Project and table groups
Project ecommerce {
database_type: 'PostgreSQL'
Note: 'Core commerce schema. Owned by the platform team.'
}
TableGroup identity {
users
user_profiles
sessions
}
TableGroup commerce {
products
orders
order_items
}Table groups render as coloured regions in the diagram, which makes a schema of forty tables comprehensible.
Schemas
Qualify table names with a dot:
Table billing.invoices {
id bigint [pk, increment]
customer_id bigint [not null]
}
Ref: billing.invoices.customer_id > public.customers.idA complete example
Project blog {
database_type: 'PostgreSQL'
Note: 'Schema for the public blog. Generated diagram in docs/schema.png.'
}
Enum post_status {
draft
published
archived
}
Table users {
id integer [pk, increment]
username varchar(50) [not null, unique]
email varchar(255) [not null, unique, note: 'login identity, lowercased on write']
password_hash varchar(255) [not null]
is_active boolean [not null, default: true]
created_at timestamptz [not null, default: `now()`]
indexes {
(lower(email)) [unique, name: 'idx_users_email_lower']
created_at
}
Note: 'Soft delete via is_active; rows are never removed for audit reasons.'
}
Table posts {
id bigint [pk, increment]
user_id integer [not null]
slug varchar(200) [not null, unique]
title varchar(200) [not null]
body text
status post_status [not null, default: 'draft']
published_at timestamptz [note: 'null until first published']
created_at timestamptz [not null, default: `now()`]
indexes {
(status, published_at) [name: 'idx_posts_status_published']
user_id
}
}
Table tags {
id integer [pk, increment]
name varchar(60) [not null, unique]
}
Table posts_tags {
post_id bigint [not null]
tag_id integer [not null]
indexes {
(post_id, tag_id) [pk]
tag_id
}
}
Table comments {
id bigint [pk, increment]
post_id bigint [not null]
user_id integer
parent_id bigint [note: 'self-reference for threaded replies']
body text [not null]
created_at timestamptz [not null, default: `now()`]
indexes {
(post_id, created_at)
}
}
TableGroup content {
posts
tags
posts_tags
comments
}
Ref: posts.user_id > users.id [delete: cascade]
Ref: posts_tags.post_id > posts.id [delete: cascade]
Ref: posts_tags.tag_id > tags.id [delete: cascade]
Ref: comments.post_id > posts.id [delete: cascade]
Ref: comments.user_id > users.id [delete: set null]
Ref: comments.parent_id > comments.id [delete: cascade]Note comments.parent_id > comments.id — a self-reference, which DBML handles and renders as a loop.
Converting DBML to SQL
DBML describes a schema; eventually you need DDL. The official @dbml/cli handles both directions:
npm install -g @dbml/cli
# DBML → SQL
dbml2sql schema.dbml --postgres -o schema.sql
dbml2sql schema.dbml --mysql -o schema.sql
dbml2sql schema.dbml --mssql -o schema.sql
# SQL → DBML
sql2dbml schema.sql --postgres -o schema.dbmlFor a one-off conversion without installing anything, a browser-based DBML to SQL converter (opens in a new tab) does the same translation in both directions — DBML to PostgreSQL or MySQL DDL, and existing CREATE TABLE statements back into DBML for diagramming.
The generated DDL for the example above looks like this on PostgreSQL:
CREATE TYPE "post_status" AS ENUM ('draft', 'published', 'archived');
CREATE TABLE "users" (
"id" serial PRIMARY KEY,
"username" varchar(50) NOT NULL UNIQUE,
"email" varchar(255) NOT NULL UNIQUE,
"password_hash" varchar(255) NOT NULL,
"is_active" boolean NOT NULL DEFAULT true,
"created_at" timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX "idx_users_email_lower" ON "users" (lower(email));
CREATE INDEX ON "users" ("created_at");
COMMENT ON COLUMN "users"."email" IS 'login identity, lowercased on write';
CREATE TABLE "posts" (
"id" bigserial PRIMARY KEY,
"user_id" integer NOT NULL,
"slug" varchar(200) NOT NULL UNIQUE,
"title" varchar(200) NOT NULL,
"body" text,
"status" post_status NOT NULL DEFAULT 'draft',
"published_at" timestamptz,
"created_at" timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE "posts"
ADD FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE;Two things to check in generated DDL before running it. Notes become COMMENT ON statements, which is a nice bonus but means the DDL is not portable to databases without comment support. And DBML has no concept of partial indexes, check constraints, triggers or partitioning — anything beyond tables, columns, keys and basic indexes has to be added separately.
Reverse-engineering an existing schema
Going the other way is how most teams start, since the database usually exists before anyone wants a diagram:
# Dump structure only, then convert
pg_dump --schema-only --no-owner --no-privileges -d appdb > schema.sql
sql2dbml schema.sql --postgres -o schema.dbmlPaste the result into dbdiagram.io and you have an accurate diagram of a schema nobody documented. It is a good way to onboard onto an unfamiliar database — and it frequently surfaces surprises, like a foreign key everyone assumed existed that does not.
If you would rather pull the DDL through a GUI than remember pg_dump flags, Chat2DB (opens in a new tab) can export a schema's DDL from PostgreSQL, MySQL and others directly, which you can then convert; the web version (opens in a new tab) works without installing anything.
Where DBML fits
DBML is a documentation and design tool. It is deliberately not a migration tool, and treating it as one causes problems.
It does not track schema versions, generate ALTER TABLE statements between two states, or know what is currently deployed. Use Liquibase, Flyway, Atlas or your ORM's migrations for that. DBML answers "what does this schema look like", not "how do I get from here to there".
The workflow that works well:
- Design a new feature's tables in DBML, paste into dbdiagram.io, and iterate on the diagram in review. It is far easier to spot a modelling mistake in a picture than in a migration file.
- Generate the DDL with
dbml2sqlas a starting point. - Write the migration properly in your migration tool — with the concurrency, backfill and rollback concerns that DBML knows nothing about.
- Commit the DBML alongside the migration so the file in the repo always matches the deployed schema.
- Regenerate periodically with
sql2dbmlagainst a real database to catch drift.
Step 5 is the one that keeps it honest. A DBML file that is never checked against reality decays exactly like the diagram it replaced. A CI job that regenerates it and fails on a diff is a cheap way to guarantee the documentation stays true:
pg_dump --schema-only --no-owner -d "$DATABASE_URL" > /tmp/live.sql
sql2dbml /tmp/live.sql --postgres -o /tmp/live.dbml
diff -u docs/schema.dbml /tmp/live.dbml || {
echo "Schema drift: docs/schema.dbml does not match the database."
exit 1
}Limitations
Be clear about what DBML does not express, so you do not mistake the diagram for the whole schema:
- Check constraints — no syntax
- Partial and filtered indexes — no syntax
- Triggers, functions, stored procedures — out of scope
- Views and materialised views — not represented
- Partitioning — not represented
- Row-level security policies — not represented
- Generated / computed columns — no syntax
- Collations, storage parameters, tablespaces — not represented
For most schemas this is fine; the tables, columns, keys and relationships are what people need to understand. But a sql2dbml round trip is lossy, and if your schema leans heavily on views, triggers or RLS, the DBML file is a partial picture and should say so in a Note.
Summary
DBML makes a schema diagram into a text file, which means it can be reviewed, versioned and regenerated rather than redrawn. The language is small enough to learn in one sitting: Table blocks with name type [settings] columns, Ref lines whose operator sets the foreign key direction, indexes blocks, Enum definitions, and Note for the context that would otherwise be lost.
Use it for design and documentation, convert to DDL as a starting point, and leave the actual migrations to a migration tool. Add a CI check that regenerates the DBML from the live schema and fails on drift, and you get something diagramming tools never managed: documentation that stays correct.
