← back to articles

type-safe sql in rust

2025-04-19 · 2 min read

moving runtime sql errors to compile time. what worked, what didn't, and what i'd do differently.

every application i have worked on has at least one production incident caused by a sql query that broke after a schema change. the column renaming, the table that got split, the type that changed from integer to text — these errors surface at runtime because the application code and the database schema drift apart.

the approach

the idea is simple: generate rust types from the database schema and use them to construct queries that the compiler can validate. if a column disappears, the code does not compile. if a query expects a text value where the schema says integer, the type system catches it before ci even finishes.

i built a small tool called ferrite that reads postgres’s information_schema at compile time and generates a module of type-safe query builders. the generated code looks like this:

let users = User::table();

let results = users
    .select(|u| (u.id, u.email, u.created_at))
    .filter(|u| u.active.eq(true))
    .order_by(|u| u.created_at.desc())
    .limit(10)
    .fetch(&pool)
    .await?;

the select call only accepts columns that exist on the table. the filter call only accepts values of the correct type. the .fetch call knows the return type at compile time.

what worked

the compile-time guarantees are real. during the six months i used this approach on a production service, exactly zero schema-related runtime errors made it to production. the compiler caught column renames before they were deployed, type mismatches before they hit an endpoint, and missing indexes before they caused a query to scan the full table.

the development cycle changed: i would write a migration, regenerate the types, and fix the broken queries in one pass. the feedback loop was minutes, not days.

what did not

the compile times were the main cost. regenerating the schema module added about twenty seconds to every build. incremental compilation helped, but any change to the schema triggered a full recompile of every query site.

the dynamic parts of sql — window functions, recursive ctes, full-text search — required escape hatches that weakened the guarantees. each escape hatch was a raw_sql function that returned dyn QueryFragment, and each one was a potential runtime failure point.

what i would do differently

next time i would separate the schema generation from the application crate. the schema module could be published as its own crate and versioned alongside the migration files. builds would stay fast, and the safety guarantees would come from the version compatibility, not the compiler.

i would also skip the query builder abstraction and generate something closer to the raw sql — typed string interpolation with placeholder injection. the builder pattern added complexity without much benefit for the common case of simple select, insert, and update queries.

conclusion

type-safe sql is worth the complexity if your schema changes faster than your team can audit every query site. for most projects, a well-reviewed migration and a staging environment are enough. but for the projects where it matters, it matters a lot.