iron_sql
iron_sql is a typed SQL code generator and async runtime for PostgreSQL. Write SQL where you use it, run generate_sql_module, and get a module with typed dataclasses, query helpers, and pooled connections without hand-written boilerplate.
Installation
pip install iron-sql # runtime only (psycopg + psycopg-pool + pydantic)
pip install iron-sql[codegen] # + inflection for code generation
The sqlc binary is bundled automatically via the sqlc Python package.
Key Features
- Query discovery.
generate_sql_modulescans your codebase for calls like<module>_sql("SELECT ..."), runssqlcfor type analysis, and emits a typed module. - Strong typing. Generated dataclasses and method signatures flow through your IDE and type checker.
- Async runtime. Built on
psycopgv3 with pooled connections, context-based connection reuse, and transaction helpers. - Streaming.
query_stream()uses server-side cursors for memory-efficient iteration over large result sets. - Safe by default. Helper methods enforce expected row counts instead of returning silent
None.
Design Constraints
Every query is a static SQL literal at its call site. That one constraint is where the guarantees come from: sqlc can type the statement ahead of time, generated Literal overloads hand your editor the exact result type, and the SQL that runs is the SQL you read. Nothing assembles a statement at runtime, so every statement text is fixed at generation time and appears verbatim in the generated module, next to the call sites that use it. That makes a hot pg_stat_statements entry easy to trace back -- though the mapping is not one-to-one: PostgreSQL folds statements that differ only in literal constants into a single entry, and splits one statement across users, databases, and search_path.
Non-goals, by construction:
- SQL fragment composition. No shared
WHEREsnippets stitched together before execution. - Dynamic query assembly. Conditional filters belong in the SQL itself (
sqlc.narg('status')::task_status IS NULL OR status = @status?), not in Python string building. - Lazy relations and object graphs. Nothing loads on attribute access; related rows come from a query you wrote.
A 1+N pattern is therefore never implicit: it is always a loop in your own code around a statement you can read. detect_sql_repeats() reports one when you write it by accident.
Package Layout
runtime.py-- asyncConnectionPool, row helpers (get_one_row,typed_scalar_row), JSON validation decorators.codegen/generator.py-- query discovery, type resolution, module rendering.codegen/sqlc.py-- wraps thesqlcCLI and models its JSON output.codegen/util.py-- shared codegen utilities (indent_block,write_if_changed).
Getting Started
-
Add a schema file. A Postgres DDL dump, e.g.
db/schema.sql. -
Write queries where they live. Import the future helper and use SQL literals inline:
from myapp.db.mydb import mydb_sql user = await mydb_sql( "SELECT id, username, email, created_at FROM users WHERE id = @user_id" ).query_single_row(user_id=uid)
Named parameters use
@param(required) or@param?(optional, expands tosqlc.narg). Positional$1works too. -
Generate the client module.
from pathlib import Path from iron_sql.codegen import generate_sql_module generate_sql_module( schema_path=Path("schema.sql"), module_full_name="myapp.db.mydb", dsn_expr="myapp.config:DSN", src_path=Path("."), )
src_pathis the root of the source scan. The scan does not enter dot-prefixed directories, virtual environments that containpyvenv.cfg, or directory symlinks. These rules apply only to child directories, sosrc_pathcan have any name. All other directories are scanned, including directories without importable Python names.This writes
myapp/db/mydb.pycontaining:- a connection pool singleton,
*_connection()and*_transaction()context managers,*_is_in_transaction()check for an open transaction,*_listen_session(channel)and*_notify(channel, payload="")helpers,- dataclasses for multi-column results (deduplicated by table),
StrEnumclasses for PostgreSQL enums,- a query class per statement with typed methods,
- overloads for the
*_sql()helper so editors infer return types.
Customization
- Type overrides.
type_overrides={"float8": "decimal.Decimal"}maps database type names to Python type strings. For built-in types the key is the PostgreSQL internal name (float8,varchar,timestamptz), not the SQL-standard spelling (double precision,character varying); for a user-defined enum, domain or extension type it is the type name as declared (custom_int,citext). A key that no query column or parameter uses raisesValueErrorlisting the type names actually in use. - JSON model overrides.
json_model_overrides={"users.metadata": "myapp.models:UserMeta"}adds Pydantic validation for JSON/JSONB columns. - Naming conventions. Supply
to_pascal_fnandto_snake_fncallables to control generated names. - Connection settings.
dsn_exprandpool_options_exprare written verbatim into the generated module; point them at config variables, env var lookups, or function calls. - Session settings and timeouts.
PoolOptions["kwargs"]reaches every pooled connection, so libpqoptionscarries server-side settings:{"kwargs": {"options": "-c statement_timeout=5000 -c lock_timeout=1000"}}.statement_timeoutbounds a single statement, not a query method: aquery_stream()that iterates for longer than the timeout is unaffected, becauseDECLAREand eachFETCHcount separately. Consideridle_in_transaction_session_timeoutalongside them to bound transactions that stay open while the application is busy elsewhere. - Debug artifacts. Pass
debug_pathto save sqlc inputs and outputs for inspection. The generator also writes each refused directory and its reason toskipped_dirs.json.
Runtime Highlights
ConnectionPoolopens lazily and reopens afterclose(), withContextVar-based connection reuse for nested contexts.*_is_in_transaction()reports whether the context connection has an open transaction. It reads the transaction status of that connection, not the presence of a connection in theContextVar, so a bare*_connection()givesFalse. When another task runs a statement on the same connection, the check waits for that statement to finish.query_stream()opens its own transaction for the read, so the check givesTruewhile a stream is iterated.*_listen_session()uses a dedicated pooled connection and doesn't reuseContextVartransaction connections.query_single_row()raisesNoRowsError;query_optional_row()returnsNone. Both raiseTooManyRowsErroron 2+ rows.query_stream()returns an async context manager yielding anAsyncGenerator; uses server-side cursors with automatic transaction management.- JSONB params are sent with
psycopg.types.json.Jsonb; JSON withpsycopg.types.json.Json. Scalar row factories validate types at runtime. json_validateddecorator applies Pydantic model validation to dataclass fields on construction.detect_sql_repeats()reports accidental 1+N loops; see below.
Detecting Accidental 1+N
detect_sql_repeats() watches for one statement executing many times in quick succession inside a single asyncio task -- the shape of a query sitting in a loop. Wrap the part of the process you want watched:
from contextlib import asynccontextmanager
from iron_sql import detect_sql_repeats
@asynccontextmanager # e.g. an ASGI lifespan in development
async def lifespan(app):
with detect_sql_repeats(executions=10, within_seconds=1.0):
yield
@pytest.fixture(autouse=True) # or a fixture that fails the run in CI
def no_repeated_queries():
with detect_sql_repeats(executions=5, within_seconds=10.0, strict=True):
yield
executions is how many runs of the same statement already look like a loop; within_seconds is the sliding window they must fall into. Both default to a deliberately quiet executions=10, within_seconds=1.0. Tune them to your code rather than to these numbers: set executions just above the largest batch you legitimately run in a loop (inserting a handful of rows one statement at a time is a normal pattern this cannot tell apart), and set within_seconds to the rough duration of one logical operation -- an HTTP request, one worker job. A window that is too wide merges neighbouring operations into a false report; one that is too narrow misses a slow loop whose every query waits on the network.
- Each repeated statement is reported once per task, as a
loggingwarning naming the call sites and the statement itself, collapsed to one line and truncated.strict=TrueraisesRepeatedQueryErrorinstead, which is what you want in CI. - Counting is per asyncio task, so a hundred concurrent handlers running the same query once each never trip it -- only a loop inside one of them does.
- Detection is off unless a block is active, and the block cannot be nested: entering a second one raises
RuntimeError.
Example
The example/ directory contains a complete working setup: a PostgreSQL schema, generation script with testcontainers, and sample query definitions. See example/generate.py for the codegen call and example/main.py for query usage.
Validation and Troubleshooting
- Errors identify the file and line where the problematic statement lives.
- Unknown SQL types map to
objectand emitUnknownSQLTypeWarning(promotable to error withwarnings.filterwarnings). - Statements with the same SQL but conflicting
row_typevalues are rejected at generation time. - A user-defined type may reuse a standard SQL spelling of a built-in (
integer,real). Enums and composite types are recognised as user-defined either way. A domain or extension type is recognised only where the column references it schema-qualified (v public."integer"), because an unqualified reference reaches iron_sql spelled exactly like the built-in; such a column resolves to the built-in, andtype_overridescannot name it.
Metadata
Release files for iron-sql 1.5.9
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| iron_sql-1.5.9.tar.gz | 31.7 kB | Without provenance | Details for iron_sql-1.5.9.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| iron_sql-1.5.9-py3-none-any.whl | Python 3 | none | any | Details for iron_sql-1.5.9-py3-none-any.whl |
Total release size: 64.7 kB
Release files / iron_sql-1.5.9.tar.gz
| Download URL | iron_sql-1.5.9.tar.gz |
|---|---|
| Size | 31.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
bbe23eb2a0ee31e8952565c8c6836193c72b0b3bdaa483bc09ed0f2948af9b94
|
|
BLAKE2b-256 checksum How to use checksums |
64e59c5d1b9cb9724f0eed94e67f165dd514b883f45214935e0557b1e97f7d0d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.19 {"installer":{"name":"uv","version":"0.12.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / iron_sql-1.5.9-py3-none-any.whl
| Download URL | iron_sql-1.5.9-py3-none-any.whl |
|---|---|
| Size | 33.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
36bdf009c8021b67f4bfdacd51b6b537189529054f89b585c72e3694e7d6c729
|
|
BLAKE2b-256 checksum How to use checksums |
286b74bd013ce41f4366e8c85e062db6f77359f87f4f874aa32ccfd0850f7f36
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.19 {"installer":{"name":"uv","version":"0.12.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Security for iron-sql 1.5.9
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.