Sustained.py

A Python query builder and schema migration tool, inspired by Objection.js

Guides

Building Queries

Model.query() returns a QueryBuilder. Chain query methods onto it to build the statement:

print(
    Show.query()
        .select('title')
        .where('sold_out', '=', True)
        .orderBy('starts_at')
)
# SELECT title FROM shows WHERE sold_out = TRUE ORDER BY starts_at ASC

The chain mutates the builder in place. If you want to branch several queries from one base query, make a deep copy with clone() first.

This page continues to use the venue booking schema from Getting Started.

Where the rows come from

The model’s table is the default source, but can be overridden with from_():

Show.query().from_('shows_archive')
# SELECT * FROM shows_archive

Show.query().from_('shows', 'r')
# SELECT * FROM shows AS r

The source can also be another query, which renders as a derived table. An alias is required there, because SQL has no name for an unnamed subquery:

sellouts = Show.query().select('id', 'title').where('sold_out', '=', True)

Show.query().from_(sellouts, 'sellouts').select('*')
# SELECT * FROM (SELECT id, title FROM shows WHERE sold_out = TRUE) AS sellouts

Choosing columns

select() takes any number of column names. Without it, the query selects everything:

Show.query().select('id', 'title', 'starts_at')
# SELECT id, title, starts_at FROM shows

Once a query joins, two tables can both have columns with the same name. Model attributes give you the qualified form without writing the table name twice:

Show.query().select(Show.title, Venue.name).innerJoinRelated('venue')
# SELECT shows.title, venues.name FROM shows INNER JOIN venues ON shows.venue_id = venues.id

An alias uses the 'column AS alias' shorthand. Both halves quote correctly for the dialect:

Venue.query().select('name AS venue_name')
# SELECT name AS venue_name FROM venues

When a join selects the same column name from two tables, alias one of them. A row is keyed by column name, so one value would otherwise replace the other, and Sustained refuses that result set with AmbiguousColumns, naming the repeated columns:

Show.query().select(Show.id, Venue.id).innerJoinRelated('venue').run()
# AmbiguousColumns: This result set returns 'id' more than once ...

Show.query().select(Show.id, 'venues.id AS venue_id').innerJoinRelated('venue').run()

distinct() adds the keyword to the whole select list:

Venue.query().distinct().select('city')
# SELECT DISTINCT city FROM venues

Aggregates

count(), sum(), avg(), min(), and max() each add one aggregate to the select list. Called with no column specified, count() counts rows:

Show.query().count()
# SELECT COUNT(*) FROM shows

Ticket.query().count('id', alias='sold')
# SELECT COUNT(id) AS sold FROM tickets

Ticket.query().sum('price', alias='gross')
# SELECT SUM(price) AS gross FROM tickets

For an aggregate with no method of its own, build an AggregateExpression and pass it to select():

from sustained.expressions import AggregateExpression

Artist.query().select(AggregateExpression('STRING_AGG', "name, ', '"))
# SELECT STRING_AGG(name, ', ') FROM artists

The argument to AggregateExpression is raw SQL, so any dialect-specific quoting is yours to write. Grouping those aggregates and filtering the groups is detailed in Grouping.

Functions

select_func() allows you to call any SQL function. String arguments are interpreted as column names. You can wrap a string value in Literal to pass it as data, and in Column to pass it as raw SQL:

from sustained import Literal

Venue.query().select_func('COALESCE', 'name', Literal('unknown'), alias='label')
# SELECT COALESCE(name, 'unknown') AS label FROM venues

A string argument that is not a plain column name raises ValueError when the query renders:

Venue.query().select_func('COALESCE', 'not a column', alias='x')
# ValueError: Function argument 'not a column' is not a column name.
# Wrap literal values in Literal() or raw SQL in Column().

The rule exists because a forgotten Literal would turn a value into a column reference, and the query would return the wrong rows instead of failing.

An argument can be another expression: a nested Func, an aggregate, a window call, or a Subquery. A subquery argument renders through the statement, so under to_sql() its values become placeholders and join the outer parameter tuple in the order they appear in the SQL. It renders without its alias, because a function argument takes a bare SELECT:

from sustained.expressions import Subquery

quota = Venue.query().select('capacity').where('id', '=', 3)

Show.query().select_func('COALESCE', Subquery(quota, 'q'), Literal(0), alias='cap')
# SELECT COALESCE((SELECT capacity FROM venues WHERE id = ?), 0) AS cap FROM shows

Every registered function also has a named method equivalent, so these build the same query:

Venue.query().select_func('COALESCE', 'name', Literal('unknown'), alias='label')
Venue.query().coalesce('name', Literal('unknown'), alias='label')

The registry covers the scalar functions LOWER, UPPER, COALESCE, CONCAT, SUBSTRING, TRIM, LENGTH, ROUND, ABS, CEILING, FLOOR, MOD, NOW, and GETDATE, plus the aggregates COUNT, SUM, AVG, MIN, MAX, and STRING_AGG. A registered function checks itself against the configured dialect and raises DialectError at build time when the engine has no spelling for it:

from sustained.dialects import Dialects

Venue.set_dialect(Dialects.MSSQL)
Venue.query().select_func('STRING_AGG', 'name')
# DialectError: Function 'STRING_AGG' is not supported by the 'MSSQL' dialect.

Some registered names change spelling instead of raising, because the engine spells the same function differently:

Venue.query().length('name', alias='n')
# DEFAULT:  SELECT LENGTH(name) AS n FROM venues
# MSSQL:    SELECT LEN([name]) AS [n] FROM [venues]

Venue.query().now(alias='t')
# POSTGRES: SELECT NOW() AS "t" FROM "venues"
# MSSQL:    SELECT GETDATE() AS [t] FROM [venues]
# DEFAULT:  DialectError: Function 'NOW' is not supported by the 'DEFAULT' dialect.

An unregistered name passes through unchecked, so you can call a function the registry does not list:

Venue.query().select_func('SOME_CUSTOM_FN', 'name')
# SELECT SOME_CUSTOM_FN(name) FROM venues

The function reference lists every registered name with its per-dialect spelling.

Window functions

select_window() takes the function name, an alias, and the partition and order columns:

Ticket.query().select_window(
    'ROW_NUMBER', 'seat', partition_by=['show_id'], order_by=['sold_at']
)
# SELECT ROW_NUMBER() OVER (PARTITION BY show_id ORDER BY sold_at) AS seat FROM tickets

Filtering on the result needs a wrapping subquery on most engines, or qualify() on DuckDB.

CASE expressions

select_case() takes the alias, the ELSE value, and the WHEN pairs. Strings in the result position are literals:

Venue.query().select_case(
    'size',
    'small',
    when_clauses=[
        ('capacity > 5000', 'arena'),
        ('capacity > 1000', 'theatre'),
    ],
)
# SELECT CASE WHEN capacity > 5000 THEN 'arena'
#             WHEN capacity > 1000 THEN 'theatre'
#             ELSE 'small' END AS size
# FROM venues

Wrap a result in Column when it names a column rather than a value:

from sustained.expressions import Column

Ticket.query().select_case(
    'charged',
    Column('price'),
    when_clauses=[('refunded_at IS NOT NULL', '0.00')],
)
# SELECT CASE WHEN refunded_at IS NOT NULL THEN '0.00' ELSE price END AS charged
# FROM tickets

The condition half of each pair is raw SQL and renders as written. A result that is neither a string nor a Column raises TypeError when the query renders.

Subqueries in the select list

Subquery embeds a whole query as one column. Reference the outer query’s columns with QueryBuilder.raw(), which stops the name being treated as a value:

from sustained.builder import QueryBuilder
from sustained.expressions import Subquery

sold = Ticket.query().count().where('show_id', '=', QueryBuilder.raw('shows.id'))

Show.query().select('title', Subquery(sold, 'tickets_sold'))
# SELECT title, (SELECT COUNT(*) FROM tickets WHERE show_id = shows.id) AS tickets_sold
# FROM shows

The inner query renders like any other part of the statement. Under to_sql() its values become placeholders and join the outer parameter tuple, in the order they appear in the SQL.

Ordering

orderBy() takes a column and an optional 'asc' or 'desc', defaulting to ascending. Call it once per sort key, in order:

Show.query().orderBy('starts_at', 'desc').orderBy('title')
# SELECT * FROM shows ORDER BY starts_at DESC, title ASC

On a query built with union(), the ordering applies to the combined result.

Limiting and paging

limit() and offset() are paired methods. Each takes a non-negative integer and can be called once:

Show.query().orderBy('starts_at', 'desc').limit(10).offset(5)
# SELECT * FROM shows ORDER BY starts_at DESC LIMIT 10 OFFSET 5

page() computes the same thing from a zero-based page number and a page size:

Show.query().page(2, 25)
# SELECT * FROM shows LIMIT 25 OFFSET 50

top() is the T-SQL spelling, and puts the limit at the front of the statement:

Show.query().top(10)
# MSSQL:   SELECT TOP 10 * FROM shows
# others:  DialectError: TOP is not supported by the 'DEFAULT' dialect. Use limit() instead.

limit() and top() on the same query raise ValueError. On MSSQL, limit() and offset() compile to OFFSET ... FETCH, which T-SQL only allows after an ORDER BY, so the query raises DialectError without one. On Presto, OFFSET renders before LIMIT. An offset() with no limit() needs a row limit on the dialects that reject a bare OFFSET: the default dialect renders LIMIT -1 OFFSET n, which SQLite reads as all rows, and MySQL renders its own all-rows limit. Postgres and DuckDB keep the bare OFFSET.

An offset deep into a large table costs a scan that grows with the offset, because the database still walks the skipped rows. cursor_page() avoids that scan:

first = Ticket.query().cursor_page('id', 100).run()
next_page = Ticket.query().cursor_page('id', 100, after=first[-1].id).run()

cursor_page() orders by the column, filters for rows past the last value you saw, and limits to the page size. The column has to be unique and sorted the same way each call, which usually means the primary key.

total() runs SELECT COUNT(*) over the query with ORDER BY, LIMIT, and OFFSET stripped, and returns the number without changing the builder. It is the row count a paged query would have had.

Common table expressions

with_() takes an alias and a query. The trailing underscore is needed to distinguish the method from Python’s with keyword:

big_venues = Venue.query().select('id').where('capacity', '>', 5000)

(Show.query()
    .with_('big_venues', big_venues)
    .join('big_venues', 'shows.venue_id', '=', 'big_venues.id')
    .select('shows.title'))
# WITH big_venues AS (SELECT id FROM venues WHERE capacity > 5000)
# SELECT shows.title FROM shows
# JOIN big_venues ON shows.venue_id = big_venues.id

recursive=True renders WITH RECURSIVE, except on MSSQL, where T-SQL spells recursive CTEs with plain WITH. Sustained does not build the anchor and recursive halves for you; you will need to write that with raw() and a union() yourself.

Combining queries

union(), unionAll(), intersect(), and except_() each take any number of builders and combine them with the matching set operator. union() removes duplicate rows and unionAll() keeps them:

sellouts = Show.query().select('id', 'title').where('sold_out', '=', True)
soon = Show.query().select('id', 'title').where('starts_at', '<', '2026-09-01')

sellouts.union(soon)
# (SELECT id, title FROM shows WHERE sold_out = TRUE)
# UNION
# (SELECT id, title FROM shows WHERE starts_at < '2026-09-01')

Each member renders inside its own parentheses and keeps its own ORDER BY and LIMIT. Clauses added to the query afterwards apply to the combination:

sellouts.union(soon).orderBy('title').limit(20)
# (...) UNION (...) ORDER BY title ASC LIMIT 20

CTEs from every member query are elevated to a single WITH at the top of the statement. Two different CTEs sharing an alias raise ValueError and must be disambiguated.

except_() uses the same trailing underscore as with_(), for the same reason.

Analyst clauses

These clauses are only supported for a subset of the available dialects.

distinctOn(*columns) keeps the first row per group, and needs an orderBy() on the same leading columns to define which row that is. For Postgres and DuckDB:

Show.query().distinctOn('venue_id').orderBy('venue_id').orderBy('starts_at')
# SELECT DISTINCT ON ("venue_id") * FROM "shows" ORDER BY "venue_id" ASC, "starts_at" ASC

qualify(condition) filters on a window function without a wrapping subquery. It takes a Predicate or a raw string. For DuckDB:

(Ticket.query()
    .select('show_id')
    .select_window(
        'ROW_NUMBER',
        'rn',
        partition_by=['show_id'],
        order_by=['sold_at'])
    .qualify('rn <= 3'))
# SELECT "show_id", ROW_NUMBER() OVER (PARTITION BY "show_id" ORDER BY "sold_at") AS "rn"
# FROM "tickets" QUALIFY rn <= 3

groupByRollup(), groupByCube(), and groupByGroupingSets() produce subtotal rows and multi-grain aggregates. They are covered with the rest of GROUP BY in Grouping.

for_update(skip_locked=False, nowait=False) locks the selected rows for the transaction. It is available on Postgres only, and not in a query that uses a union.

Reading the execution plan

explain() runs the dialect’s EXPLAIN and returns the plan rows. explain(analyze=True) uses EXPLAIN ANALYZE, which runs the statement for real, so do not point it at a write. MSSQL raises, because T-SQL has no EXPLAIN statement.

Reusing a query

Each chained call adds to the same builder, so a shared base query collects every branch’s filters. To build several queries from one base, clone() each branch first:

base = Show.query().where('sold_out', '=', True)

fillmore = base.clone().where('venue_id', '=', 1)
# SELECT * FROM shows WHERE sold_out = TRUE AND venue_id = 1

first_ave = base.clone().where('venue_id', '=', 2)
# SELECT * FROM shows WHERE sold_out = TRUE AND venue_id = 2

Without the clones, the second line would filter on both venues and return nothing.

Method naming

The canonical names are camelCase: orderBy, groupBy, whereIn, unionAll, leftJoin. Each also accepts its snake_case spelling: order_by, group_by, where_in, union_all, left_join. The translation is mechanical, and it uppercases the letter after each underscore, so whereILike is where_i_like. (Method names are also not case sensitive, so you could probably use Mocking Spongebob case if you wanted to .iNnErJoIn() something. Note that at time of writing I haven’t actually tested this, but am pretty sure it holds.)

Getting the SQL out

str(query) renders values inline as SQL literals. It is for reading and logging:

print(Show.query().select('title').where('id', '=', 1))
# SELECT title FROM shows WHERE id = 1

to_sql() returns the SQL with placeholders and with the parameters as a separate tuple, in the order they appear.

Show.query().select('title').where('id', '=', 1).to_sql()
# ('SELECT title FROM shows WHERE id = ?', (1,))

The placeholder follows the dialect, ? by default and on MSSQL, %s on Postgres:

Show.set_dialect(Dialects.POSTGRES)
Show.query().select('title').where('id', '=', 1).to_sql()
# ('SELECT "title" FROM "shows" WHERE "id" = %s', (1,))

Where to go next

You want to Read
Narrow the rows Filtering
Aggregate and filter the groups Grouping
Bring in a second table Relations and Joins
Run it, write rows, use a transaction Executing Queries
Know what a given engine refuses SQL Dialects
Look up a method exactly QueryBuilder reference