Repository navigation
✨ Generated columns: a field the database computes
A field can now be declared as one the database works out from the other columns of the same row, on every write. Declare it with generated= and a lambda in the shape you already use for where():
from ferro import Field, Model, fn
class Transaction(Model):
id: int | None = Field(default=None, primary_key=True)
amount_minor: int
direction: int = Field(
generated=lambda transaction: fn.sign(transaction.amount_minor),
index=True,
)Every migration strategy writes the same column, create_tables(), connect(auto_migrate=True), ferro migrate new and the Alembic bridge alike. On PostgreSQL:
"direction" integer NOT NULL GENERATED ALWAYS AS (CAST(sign(CAST("amount_minor" AS bigint)) AS bigint)) STOREDOn SQLite the same clause, plus a named check that holds the value to PostgreSQL's integer range, so a value that fits on one database fits on the other.
The database is the only evaluator
Ferro never computes the value in Python. An instance holds what the database last stored, or None while that is unknown:
transaction = Transaction(amount_minor=-1200)
print(transaction.direction) # None: no row yet
await transaction.save()
print(transaction.direction) # -1, read back through RETURNING
transaction.amount_minor = 500
print(transaction.direction) # None again: the stored value no longer matches
await transaction.save()
print(transaction.direction) # 1save() never lists the column in its INSERT or UPDATE and asks for it back with RETURNING on both dialects. Passing direction= to the constructor, assigning it, update(direction=...) and save(only={"direction"}) are each refused with a TypeError that names the input columns to change instead. The column filters, orders, projects and indexes like any other.
fn: the functions both databases agree on
ferro.fn holds the database functions an expression may call: sign, abs, least, greatest, coalesce, length, year, month and day, beside +, -, * and / by a non-zero literal. The table is closed on purpose: an expression is admitted only when SQLite and PostgreSQL store the same value for it on every row and fail on the same rows, and Ferro checks that when the class is defined. A float product is one they disagree on, so it is refused with the fix in the message:
TypeError: Rate.doubled '*' over a float: Postgres raises when a float product or quotient underflows to zero where SQLite stores 0.0, so the databases would disagree; keep the arithmetic in integers (an amount in minor units, such as amount_minor) or compute the value in the application
An overflow at any step raises DataError naming the column on both databases, where SQLite alone would have stored a real or inf.
Table checks call fn too
A Check in __ferro_checks__ may now call the same functions, under the same parity rules:
class Refund(Model):
__ferro_checks__ = (
Check("within_limit", lambda refund: fn.abs(refund.amount_minor) <= 50000),
)
id: int | None = Field(default=None, primary_key=True)
amount_minor: intEvery change, through every door
Adding a generated column, changing its expression, making it plain again (values kept) and making a plain column generated (destructive, so it waits for migrate_destructive or a migration) are planned once and run by auto-migrate, written into step files by ferro migrate new, and translated by the Alembic bridge. PostgreSQL runs them in place with SET EXPRESSION and DROP EXPRESSION; SQLite rebuilds the table in a generated migration. A generated column needs no backfill, since the database computes it for every existing row.
Drift now sees expressions. ferro migrate drift reports a hand-edited one as transaction.direction generated expression differs. On PostgreSQL, which stores a normalised reprint of the expression rather than your text, Ferro asks the server to print the declared body and the stored one side by side in one read-only EXPLAIN per table, so a reprint is never a phantom diff. The same comparison now covers table checks, which used to be compared as text.
Guide: Generated columns, with the functions in fn and value parity. What each door runs per change: Migrations reference. Design notes: ADR-0058, ADR-0059 and ADR-0060 in docs/adr/. (#790, #814)
⚠️ PostgreSQL 17 is the floor
connect() refuses a PostgreSQL server older than 17, before auto-migrate runs and before the connection is registered:
InterfaceError: Ferro needs PostgreSQL 17 or newer; postgresql://app:***@db/app runs PostgreSQL 16.4 (server_version_num 160004). Upgrade the server to 17 or later.
Ferro is tested on 17 and 18, and the CI matrix now runs both. SQLite has no version floor. If your database runs 16 or older, upgrade it before taking this release. (#791, #798)
Also in this release
- A refused Postgres connection fails at once. A wrong port or a server that is not listening used to hang
connect()andferro migrate status --url …for 30 seconds before reporting a pool timeout. The engine now opens one direct connection first, so the refusal comes back immediately, as itself. A server that refuses connections while it starts up is no longer retried for those 30 seconds; callers that relied on that implicit wait retry themselves. (#823) - A refused model class leaves nothing registered. A class definition Ferro rejects, for a bad
generated=expression or any other reason, no longer leaves a half-registered model behind that a laterconnect()would trip over. (#812, #813)
v0.27.0 (2026-10-30)
Bug Fixes
- A refused Postgres connection fails at once; dev builds run tests at release speed; drop six duplicate tests (#823,
58583d8)
Continuous Integration
-
Fail the main test job when the Codecov upload is rejected (#802,
fde5d9b) -
One dependency cache per Python version, named in the shared key (#809,
81c5c18) -
Pass the repository secrets to the release's CI gate (
90dd4ba) -
Run the test suites in parallel and the docs tests once (#803,
190d325) -
Upload coverage to Codecov with the org token (#799,
29b88d3)
Features
- Generated columns, a field the database computes, through every migration door (#790) (#814,
f6dfcb6)
Refactoring
Detailed Changes: v0.26.1...v0.27.0