
After drift: one warning category
ptah schema serve currently reads Go annotations only for the desired
schema. It does not accept a schema file or external loader. Use
generated HTML schema documentation for a source-neutral
browsable reference, or ptah schema drift
for a source-neutral live-database check with a pipeline exit code.
ptah schema serve serves a read-only web page carrying two things: the desired
schema, and how a live database differs from it. The page re-reads both on every
request, so where ptah schema compare answers once, this keeps answering while
you work.
Use it as a second window beside the editor while you are changing models. It is
not a pipeline tool: a pipeline wants an exit code, which
ptah schema drift gives it. Nothing here
writes to the database, and no migration depends on the server having run.

The live view before and after one desired column diverges from the SQLite database.
What to notice: Both states use the same framing: the verdict and severity counters stay above the schema diagram, while the drift state adds the exact differing category.
ptah schema serve --root-dir docs/site/fixtures/schema-ui/internal/models --db-url sqlite://shop.db --addr 127.0.0.1:7070 --refresh 0 --title 'Shop schema'Prerequisites:
ptah binary on your machine (Install Ptah).schema serve reads that source and no other; see
Limitations.The examples use two annotated models and a local SQLite database that already
matches them. Save this as models/schema.go:
package models
//ptah:schema:table name="customers"type Customer struct { //ptah:schema:field name="id" type="INTEGER" primary="true" ID int //ptah:schema:field name="email" type="TEXT" not_null="true" unique="true" Email string}
//ptah:schema:table name="orders"type Order struct { //ptah:schema:field name="id" type="INTEGER" primary="true" ID int //ptah:schema:field name="customer_id" type="INTEGER" not_null="true" foreign="customers(id)" CustomerID int}Create the database from it:
ptah schema apply --root-dir ./models --db-url "sqlite://$PWD/shop.db" --auto-approveSubstitute your own model directory and database URL throughout.
ptah schema serve --root-dir ./models --db-url "sqlite://$PWD/shop.db"Expected output includes:
Serving a read-only schema view on http://127.0.0.1:7070Open the printed address in a browser. The address is read back from the
listener rather than echoed from the flag, so --addr 127.0.0.1:0 prints the
port that was actually chosen.
The page has two parts: a status panel this command adds, and the schema itself.
The status panel carries four counters — differing categories, destructive,
warning, safe — followed either by the sentence
The database matches the declared schema. or by a Drift table. Under it
sits a timestamp reading compared and a UTC time, because a live view whose
age is unknown is not a live view.
The matching view keeps the status decision above the diagram and table reference, so the first viewport answers whether action is needed.
| Drift column | What it holds |
|---|---|
| Category | The difference category, for example columns_added. |
| Objects | How many objects fall in that category. |
| Severity | safe, warning, or destructive. |
Those are the categories and severities
ptah schema drift reports, from the same
classification.
Below the panel is the schema, rendered by the same code as
ptah schema export --to html: an entity diagram, then one
section per table with its columns and indexes. The sidebar names the page, the
database address with any credentials removed, and each table.
The models and the database are read again for each request, so a change to
either shows up on the next page load with no restart. Add this field to the
Order struct in models/schema.go:
//ptah:schema:field name="placed_at" type="TEXT" PlacedAt *stringReload the page. The counters read one differing category, of which one is a
warning, and the Drift table gains a row: columns_added, 1 object, severity
warning. The new column also appears in the orders section below, because
that section renders the desired schema rather than the database.
The page reloads itself every 30 seconds through a meta refresh tag and
carries no JavaScript. --refresh 15s shortens the interval; --refresh 0
serves a page that does not reload.
--title "Shop schema" replaces the default title, Schema dashboard, in the
browser tab, the heading and the sidebar.--addr selects the listen address. The default is 127.0.0.1:7070.--refresh sets the self-reload interval as a Go duration, and 0 disables
it.--root-dir is repeatable, so a schema split across directories is served
from one page.--schemas limits the database read to the named schemas, comma-separated.Each of these also reads an environment variable, printed on its --help line:
PTAH_TITLE, PTAH_ADDR, PTAH_REFRESH, PTAH_ROOT_DIR, PTAH_SCHEMAS.
There is no JSON endpoint. The HTML is the only machine-readable answer, and
every path returns the same page, so curl on / is a complete client:
curl -s -o dashboard.html -w '%{http_code}\n' http://127.0.0.1:7070/curl -s -X POST http://127.0.0.1:7070/Expected output includes:
200this dashboard is read-onlyGET and HEAD answer; every other method gets 405 with the header
Allow: GET, HEAD. The refusal is applied before any route runs, so the surface
stays read-only whatever is added to it. Responses carry Cache-Control: no-store, since a cached copy would show drift that has since been fixed.
For a page you scrape, add --refresh 0. The meta tag is then absent and
nothing else about the response changes.
Press Ctrl-C. Ptah writes one line to stderr and exits 130:
interrupt received, releasing resources; interrupt again to stop immediatelyThe database cannot be reached. The response stays at HTTP 200 and the
counters are replaced by a banner headed The database could not be compared,
the driver’s error, and last attempt with a UTC time. Zero drift is not
rendered in that case, because a page reading zero would tell a reader their
schema is in sync when nothing was measured. The schema section keeps the last
comparison that succeeded, and before the first success there is no schema
section at all.
Three inputs stop the command before it listens, each with exit code 2:
| Message on stderr | Cause |
|---|---|
error: database URL is required |
No --db-url, and no url: in a project config in the working directory. |
error: listen on 127.0.0.1:7070: listen tcp 127.0.0.1:7070: bind: address already in use |
Another process holds the address. Pass a different --addr. |
error: unknown flag: --schema-file |
A flag schema serve does not register. Run ptah schema serve --help for the set it does. |
--schema-file is not
registered, so a YAML, HCL, SQL, DBML or oci:// schema cannot be served. The
reason is in the long help: such a file may name an oci:// artifact, and a
process that re-reads on a timer would put a registry request on a schedule.
ptah schema drift reads those sources and
answers once.--root-dir, the working directory is scanned. Started where no
annotated Go file exists, the desired schema is empty and the whole database
reads as drift to remove: against the database built above, the panel reports
2 differing categories with 2 destructive, rows constraints_removed (4
objects) and tables_removed (2 objects), and a sidebar reading 0 tables.
Nothing on the page says the desired side was empty.0 differing categories here, while ptah schema compare reports
views_added (1) for the same pair./healthz receives a full schema render
at 200, and a reverse proxy cannot route by path.127.0.0.1:7070 keeps the
page on the machine that runs it, and binding wider publishes an
unauthenticated read of the schema.--config or --env flag. A ptah.yaml or atlas.hcl in the
working directory is still read, and a malformed one stops the command before
it listens, but neither can be pointed elsewhere.Run ptah schema serve --help for every flag with its default and its
environment variable, and for the long-form note on --schema-file. The process
runs until it is interrupted: the inputs above exit 2 before it listens, and
Ctrl-C exits 130. Exit codes carries the verb’s
row and the convention behind those numbers, and
A live view carries the flag
table.