Skip to content

The generated code

What castiron writes into your repository, and what it promises about it. This page is about the output — the fidelity of the input is a separate page.

It is clean as emitted, not cleaned afterwards

castiron runs no formatter over its output. There is no post-hoc ruff or black pass whose version could change the bytes, no timestamp, and no source URL in the file — the same schema, the same options and the same castiron version produce the same bytes, every time. The one thing the file records about how it was made is the castiron version, on its first line; see The provenance header below. That is what makes generated models safe to commit and to diff, and it is the foundation castiron check stands on.

The provenance header

Every generated module opens with exactly two comment lines and one blank line:

# Generated by castiron 0.5.0
# Do not edit by hand. Regenerate with `castiron gen` -- https://github.com/kmbhm1/castiron

from __future__ import annotations

It is a comment block, not a module docstring, so it never becomes your module's __doc__ and it never displaces from __future__ import annotations — a __future__ import must be the first statement, and a comment is not a statement.

What is in it, and what deliberately is not. The castiron version, and nothing else. No timestamp: the file would then change on every run and stop being diffable. No source URL: a --from https://project.supabase.co/?apikey=… would write a credential into a file you commit. No configuration summary: every flag you flipped would show up as a header diff. The version is the only varying input, which is what keeps the output byte-stable.

Line 1 is a stable, machine-readable contract. The version is the last whitespace-delimited token of the first line, with no trailing punctuation:

# Generated by castiron <version>

Parse that and nothing else. Line 2 is prose and may be reworded in any release. A file generated by one castiron will always be readable by a later one — which is the whole point of recording the version: castiron check tells you "generated by castiron 0.5.0; you are running 0.6.0" instead of merely failing, so a castiron upgrade does not look like a schema change.

Being unformatted afterwards means the emitter has to get it right the first time, so it does:

The promise

Emitted output is clean under ruff's F (Pyflakes), UP (pyupgrade) and I (isort) rules, at ruff's own default settings — not castiron's. A generated module does not trip the linter of the project it was just added to.

The import block in particular is written pre-sorted: sections in __future__ → standard library → third party order, import X before from X import ..., same-module from imports merged onto one line with names ordered constant → class → rest, all case-insensitive. It is byte-identical to what ruff check --select I --fix would have written.

What is not promised, stated as plainly as the promise:

  • Nothing about E501. castiron's own house limit is 120 columns and the longest line it has been measured to emit is 101 characters, but that is an observation, not a guarantee — the length is driven by Field(description=...) carrying your own SQL comment, and your comments are as long as you wrote them. At ruff's 88-column default, a long comment will be flagged.
  • Nothing about non-default rule sets. D, ANN, PL, and friends are not considered.

The promise is enforced, not asserted: every reachable emission (4 corpus inputs × 128 configuration points = 512 modules) is written to disk as .py and linted by a real ruff subprocess with --isolated, on every leg of the test matrix. Adding an allowance list back is a deliberate decision, not a way to turn a red test green.

That sweep covers all four corpus inputs, including the deliberately identifier-hostile one. Until 0.1.0 it covered three: the hostile input emitted a module that did not parse, and a linter has nothing to say about a file it cannot read. Column names are now repaired, so the carve-out is gone rather than merely unused.

Model class names

Every table produces a class stemordersOrders — and five classes hang off it: OrdersBaseSchema, OrdersParent, OrdersInsert, OrdersUpdate and the operational Orders. A table name is a quoted identifier in Postgres, so CREATE TABLE "order lines" is legal and PostgREST reports it verbatim.

The rule, in order:

  1. Sanitize, then PascalCase. Every character Python will not accept inside an identifier becomes _ — the same map the column and enum label paths use — and to_pascal_case then splits on _. So a space or a hyphen becomes a word boundary: "order lines"OrderLines. A leading digit gains one _. Unicode is kept: Ünïcödé comes through untouched.
  2. Singularize first, when --singular-names is set. ordersOrder.
  3. Collision resolution — see below.
Postgres table Class stem
order_lines OrderLines
"order lines" OrderLines
"order-lines" OrderLines
ORDER_LINES OrderLines
"2fast" _2fast
"a""b" AB
"Ünïcödé" Ünïcödé

Until 0.1.2 these emitted a module that did not parse

"order lines" emitted class Order linesBaseSchema(CustomModel): — a SyntaxError, with castiron gen exiting 0 — and the bad stem reached all five class headers, every relationship field pointing at that table, and each class's docstring. No name that was already valid changed, so regenerating moves nothing unless your schema contains one of these.

orderLines becomes Orderlines, not OrderLines

The table path capitalizes each _-separated word and lowercases the rest, so a camelCase table name loses its inner capitals. That is what castiron has always emitted and it is not a parse problem, so it was left alone. (Enum type names do have a camelCase branch — the two transforms are not the same function.)

Colliding tables get an ordinal suffix

Three different things collapse two tables onto one set of class names, and all three predate the repair above:

  • The assembly is not injective: order_lines, ORDER_LINES, "order lines" and "order-lines" all become OrderLines.
  • --singular-names merges names: orders and order both become Order.
  • A stem binds five class names, not one: the tables order and order_insert have obviously distinct stems and both want the name OrderInsert.

Nothing is ever dropped or merged. Every table gets its own five classes; the ones that cannot keep the natural stem get _2, _3, … and a comment saying what took it. A stem is only allocated when every name derived from it is free — including against castiron's own CustomModel bases and everything the import block binds, so a table called custom_model or base_model cannot rebind them.

Well-behaved names are allocated first

Tables whose name needed no repair claim their stem before repaired ones do, the same way enum classes do. Adding CREATE TABLE "order lines" to a database that already had order_lines should not rename a class you already import. Two equally well-behaved colliders still need an arbitration, and there the first in schema order keeps it.

CREATE TABLE public."order lines" (id int primary key);
CREATE TABLE public."order-lines" (id int primary key);
CREATE TABLE public.order_lines   (id int primary key);
# original name was "order lines" (name collision, OrderLines is taken by "order_lines")
class OrderLines_2BaseSchema(CustomModel):
    """OrderLines_2 Base Schema."""

    # Columns
    id: int


# original name was "order-lines" (name collision, OrderLines is taken by "order_lines")
class OrderLines_3BaseSchema(CustomModel):
    """OrderLines_3 Base Schema."""

    # Columns
    id: int


class OrderLinesBaseSchema(CustomModel):
    """OrderLines Base Schema."""

    # Columns
    id: int

The comment is repeated above every class the stem produces, because a class header carries nothing else of the source: once the stem is repaired the Postgres table name is otherwise unrecoverable from the module. It is emitted only when the name changed.

Relationship fields follow the resolved class, never the one the table name suggests — a foreign key into "order lines" is annotated list[OrderLines_2] above, not list[OrderLines]. Their field names are model fields, so they follow the column rule instead: a table called "order lines" gives order_lines: list[...], and one called class gives field_class.

castiron gen prints one aggregated line to stderr when a table is renamed:

castiron: 2 tables are not emitted under the class name their name suggests
(order lines -> OrderLines_2 (name collision, OrderLines is taken by order_lines),
order-lines -> OrderLines_3 (name collision, OrderLines is taken by order_lines)) -- the
original table name is preserved in a comment above each generated class, so the module
still records which table it came from; only the Python class name differs.

The same positional caveat as everywhere else

Ordinal suffixes are positional, so adding a table upstream that sorts before an existing collider renumbers the later ones. Allocating unrepaired names first removes the common case — a hostile name displacing a well-behaved one — but not the general one.

Enum class names

The class header is built from two pieces of raw Postgres text — the schema and the type name — and PostgREST reports both verbatim. A Postgres type name is a quoted identifier, so CREATE TYPE public."order status" and CREATE SCHEMA "2fa" are both perfectly legal and neither produces a usable Python class name on its own.

The rule, in order:

  1. PascalCase assemblypublic.order_statusPublicOrderStatusEnum. snake_case, camelCase (thirdTypePublicThirdTypeEnum), PascalCase and a leading underscore (_first_typePublicFirstTypeEnum) are each handled; the schema is capitalized and prefixed, and Enum is always appended.
  2. Identifier repair — every character that cannot appear in a Python identifier becomes _, and a leading digit gains one _. Unicode is kept, not folded to ASCII: public.Ünïcödé stays PublicÜnïcödéEnum.
  3. Collision resolution — see below.

This is the same character map the column and enum label paths use, so a name that is repaired one way in one place is repaired the same way everywhere.

Postgres type Class name
public.order_status PublicOrderStatusEnum
public."order status" PublicOrderStatusEnum
public."order-status" PublicOrderStatusEnum
public."a""b" PublicABEnum
"my schema".order_status My_schemaOrderStatusEnum
"2fa".mood _2faMoodEnum

Until 0.1.1 these emitted a module that did not parse

public."order status" emitted class PublicOrder statusEnum(str, Enum): — a SyntaxError, with castiron gen exiting 0. A type name containing a newline split the header across two lines. The bad name also propagated into every column annotation referencing the type, so a fix that repaired only the header would not have been one. No name that was already valid changed, so regenerating moves nothing unless your schema contains one of these.

Collisions get an ordinal suffix

Two distinct Postgres types can want the same class name, and this is not exotic: order_status, orderStatus, OrderStatus, Order_Status, _order_status and ORDER_STATUS all assemble to PublicOrderStatusEnum. The collision domain is the whole module, not just the enums — a table named order_status_enum produces a model class called OrderStatusEnum, which an enum order.status also wants.

Nothing is ever dropped or merged. Every type gets its own class; the ones that cannot keep the natural name get _2, _3, … and a comment saying what took it.

Well-behaved names are allocated first

Enum types whose name needed no repair claim their class name before repaired ones do. Without that, adding CREATE TYPE "order status" to a database that already had order_status would hand the new hostile type the clean PublicOrderStatusEnum and silently rename the working one — because ' ' and '-' sort before '_'. A schema addition should not rename a class you already import.

Two equally well-behaved colliders still need an arbitration, and there the first in schema order keeps the name.

Table model class names always win. Model class stems are allocated first and an enum yields to them — including to a stem that itself carries an ordinal — because a table's model is the stable thing your imports point at.

CREATE TYPE public."order status" AS ENUM ('open');
CREATE TYPE public."order-status" AS ENUM ('shut');
CREATE TYPE public.order_status   AS ENUM ('done');
CREATE TYPE "2fa".mood            AS ENUM ('ok');
# original name was "public.order status" (name collision, PublicOrderStatusEnum is taken by "public.order_status")
class PublicOrderStatusEnum_2(str, Enum):
    OPEN_ = "open"  # original name was "open" (reserved keyword)


# original name was "public.order-status" (name collision, PublicOrderStatusEnum is taken by "public.order_status")
class PublicOrderStatusEnum_3(str, Enum):
    SHUT = "shut"


class PublicOrderStatusEnum(str, Enum):
    DONE = "done"


# original name was "2fa.mood" (identifier repair)
class _2faMoodEnum(str, Enum):
    OK = "ok"

A class header carries nothing else of the source, so the # original name was … comment is the only record of which Postgres type a class came from — unlike a member line, where the value literal on the same line already is the label. It is emitted only when the name changed.

castiron gen also prints one aggregated line to stderr when this happens, so a rename is not something you have to find by reading the file:

castiron: 1 enum type is not emitted under the class name their Postgres type name suggests
(public.order status -> PublicOrderStatusEnum_2 (name collision, PublicOrderStatusEnum is taken by
public.order_status)) -- the original type name is preserved in a comment above each class, so the
generated module still records which type it came from; only the Python class name differs.

The same positional caveat as label collisions

Ordinal suffixes are positional. Adding a type upstream that sorts before an existing collider renumbers the later ones, so PublicOrderStatusEnum_2 can come to mean a different Postgres type after a CREATE TYPE. Allocating unrepaired names first removes the common case — a hostile name displacing a well-behaved one — but not the general one.

Enum member names

A Postgres enum becomes a str, Enum class. The member value is always the label, verbatim — whatever castiron has to do to the name, nothing is ever lost, dropped or merged, and every label gets exactly one member.

CREATE TYPE public.ticket_state AS ENUM
  ('in progress', 'in-progress', 'done', '2nd pass', '(none)', 'import', '');
class PublicTicketStateEnum(str, Enum):
    IN_PROGRESS = "in progress"
    IN_PROGRESS_2 = "in-progress"  # original name was "in-progress" (name collision)
    DONE = "done"
    _2ND_PASS = "2nd pass"
    _NONE__ = "(none)"  # original name was "(none)" (reserved by Enum)
    IMPORT_ = "import"  # original name was "import" (reserved keyword)
    _ = ""

The trailing comment appears only when the name is not the straight transform of the label. The value literal is right there on the same line and already is the label, so glossing every member would be noise in every user's file forever.

How a label becomes a name

In this order:

  1. Sanitize. Every character Python will not accept inside an identifier becomes _, one character out per character in. Nothing is collapsed and nothing is stripped: 'a b' and 'a b' stay distinguishable attempts. Non-ASCII identifier characters are kept, not folded to ASCII — 'Ünïcödé' becomes ÜNÏCÖDÉ, not ________.
  2. Uppercase. pendingPENDING, as it always has been.
  3. Empty label guard. CREATE TYPE t AS ENUM ('') is legal Postgres; the empty label becomes _.
  4. Leading-digit guard. '2nd pass'_2ND_PASS, addressable as E._2ND_PASS.
  5. Enum-shape repair — see below.
  6. Reserved-name guard. A name that spells a Python keyword or builtin gets a trailing underscore: importIMPORT_. A short curated list of builtins — id, credits, copyright, license, help, property, sum — is exempt and keeps its plain name.
  7. Collision resolution — see below.

Enum reserves shapes that Python allows

str.isidentifier() is necessary and not sufficient. Four name shapes are legal Python identifiers that EnumMeta will not give you back, and step 1 produces all of them from ordinary labels — a trailing space in a CREATE TYPE is enough:

Shape What Enum does Reached from
_sunder_ raises ValueError when the class body runs — the whole generated module is unusable at import '(none)'_NONE_
__dunder__ the member is silently dropped '__init__'__INIT__
__private (two leading underscores, fewer than two trailing) name-mangled at compile time, so the member never has the name that was written; 3.11+ drop it, 3.10 keeps it under a different name the collision suffix itself: _ + _2__2
_<ClassName>__x (class-private) treated as a normal attribute rather than a member — 3.11+ drop it, 3.10 keeps it mangled a label whose NFKC form spells the enclosing class name; see below

castiron repairs those shapes by appending _ until the name is usable — at most three times, which is a bound rather than an observation. That is why '(none)' emits _NONE__ and not _NONE_.

Three of those four would violate the one thing enum naming never does — drop a label — and they would do it at exit code 0, which is why the repair exists rather than a warning.

The fourth shape depends on the class name, not just the label

Enum's private-name check also consults the enclosing class name, so whether a name is reserved is a property of the pair. castiron checks members against the class name it actually writes in the header — including the _2 a collision allocated — so the two cannot disagree.

Reaching this shape takes deliberately-crafted Unicode and is impossible from ASCII-only labels: the generated class name always ends in Enum, whose lowercase num cannot survive .upper(). But 389 codepoints are upper()-invariant and NFKC-fold to an ASCII lowercase letter, so '_PᵘᵇˡᵢᶜOʳᵈᵉʳSᵗªᵗᵘˢEⁿᵘᵐ__X' normalizes to _PublicOrderStatusEnum__X. It is repaired to _PᵘᵇˡᵢᶜOʳᵈᵉʳSᵗªᵗᵘˢEⁿᵘᵐ__X__ and the label survives identically on Python 3.10 through 3.13 — before this was repaired, the generated module's meaning depended on which interpreter imported it.

Names are compared after NFKC normalization

CPython normalizes identifiers to NFKC at compile time, so two strings that look different can be one binding: fi = 1; fi = 2 leaves a single name worth 2. str.upper() performs the same folding ('fi'.upper() == 'FI', 'ß'.upper() == 'SS'). castiron therefore tests both the reserved shapes and uniqueness against the normalized candidate — the string the compiler will actually see — rather than the one it wrote.

Without that, a perfectly legal Postgres enum would emit a module raising TypeError at import. With it:

class PublicKindEnum(str, Enum):
    FI = "fi"
    FI_2 = "fi"  # original name was "fi" (name collision)
    ID = "id"
    SS = "ß"
    SS_2 = "SS"  # original name was "SS" (name collision)

Collisions get an ordinal suffix

When two labels want the same name, the first keeps it and each later one gets _2, _3, … until the normalized candidate is free. The suffix can itself create a reserved shape, so the result is re-repaired before it is accepted.

The cost, stated up front

Ordinal suffixes are positional, so inserting a label upstream that sorts before an existing collider renumbers the later onesE.IN_PROGRESS_2 can come to mean a different label after an ALTER TYPE. The effect is bounded to colliding labels and it is visible on the line, because the value literal sits right next to the name. The alternative, a label-derived digest suffix, is stable but permanently unreadable; refusing to generate is not an option while there is no override mechanism to point you at.

id, license, help and four others are exempt

A short curated list — id, credits, copyright, license, help, property, sum — is exempt from step 6, so those labels keep their plain names: id emits ID = "id", with no trailing underscore and no comment. The same list exempts the same names on the column path, so the two read it identically.

Until 0.1.0 the enum path applied that list as an addition rather than an exemption, so id emitted ID_ = "id" # original name was "id" (reserved keyword) — a rename the list exists to prevent, annotated with the opposite of the reason. If you generated with 0.1.0, regenerating will drop the trailing underscore from those seven names. The label in the value literal was never affected.

Column names

A column name is a quoted identifier in Postgres, which means it can be almost any string — "2fast", "space name", "kebab-case", "_private" — and PostgREST carries it through verbatim. Almost none of those are usable as a Pydantic field name, so castiron repairs the attribute and keeps the wire name on a Field(alias=...).

The value on the wire is always the real column name. The generated models read and write the column your database actually has; only the attribute you type in Python differs.

class HostileColumnsBaseSchema(CustomModel):
    """Hostile Columns Base Schema."""

    # Primary Keys
    id: int

    # Columns
    field_2fast: str = Field(alias="2fast")
    kebab_case: str | None = Field(default=None, alias="kebab-case")
    ok_column: str | None = Field(default=None)
    space_name: str | None = Field(default=None, alias="space name")

The rule

  1. Sanitize. Every character Python will not accept inside an identifier becomes _, one character out per character in — the same map the enum path uses. There is no run-collapsing and no stripping, so 'a b' and 'a b' stay distinguishable attempts. Non-ASCII identifier characters are kept: a column named Ünïcödé is already legal Python and comes through untouched, with no alias.
  2. Prefix field_ if the result still is not usable. That covers an empty name, a leading digit (2fastfield_2fast), and a leading underscore (_privatefield__private) — Pydantic reserves leading-underscore names for private attributes and raises NameError when the class body runs, so a leading underscore is unusable even though it compiles.
  3. Prefix field_ if it is a Python keyword, a builtin, or starts with model_. This is the long-standing rule: classfield_class. Seven names are exempt and keep their spelling — id, credits, copyright, license, help, property, sum.
  4. Uniquify, per table. The first column to want a name keeps it; each later collider gets _2, _3, … 'space name', 'space-name' and 'space_name' in one table become space_name, space_name_2 and space_name_3. Nothing is ever dropped or merged.

Ordering is the column order the source reported — Postgres attnum — so the result is deterministic for a given schema.

and fi are the same identifier to Python

CPython normalizes identifiers to NFKC at compile time, so two column names that look different can be one attribute. castiron tests every guard, the uniqueness key and the alias rule against the normalized form — the string the compiler will actually see. Without that, a column named would bind the attribute fi, carry no alias, and its wire name would be silently lost.

castiron tells you when it renames something

A repair changes the attribute you have to type, so gen says so — once per run, at WARNING, and the run still exits 0 because the generated code is correct:

castiron: 3 column names are not usable as a Python field name and were renamed
(t.2fast -> field_2fast, t.space name -> space_name, t.kebab-case -> kebab_case) --
the original name is preserved on the wire via Field(alias=...), so the generated
models still read and write the real column; only the attribute you type in Python differs.

The long-standing keyword rename (classfield_class) does not warn: that behaviour has not changed, and a schema that worked yesterday should not grow a new warning today.

field_ and _2 are part of the public shape

The attribute name is what your code types, so changing this scheme later would be a breaking change. Two consequences worth knowing up front: the field_ prefix is permanent, and ordinal suffixes are positional — adding a column upstream that sorts before an existing collider can renumber the later ones. ALTER TABLE … ADD COLUMN appends at the end, so in practice a new column can only take a higher ordinal. The alias on each line disambiguates them for a reader either way.