Skip to content

Annotations

Scythe uses SQL comment annotations to control code generation. All annotations start with -- @.

Names the query. Used as the generated function and struct name.

-- @name GetUserById
-- @returns :one
SELECT id, name FROM users WHERE id = $1;

Generates: get_user_by_id() function and GetUserByIdRow struct.

Specifies the query return type. Must include a colon prefix.

Value Description Use Case
:one Returns exactly one row (errors if missing) SELECT … WHERE id = $1
:opt Returns zero or one row (nullable/optional) SELECT … WHERE email = $1
:many Returns multiple rows SELECT … WHERE status = $1
:exec Returns nothing INSERT, UPDATE, DELETE without RETURNING
:exec_result Returns affected row count UPDATE/DELETE when you need the count
:exec_rows Returns affected rows Similar to exec_result
:batch Batch execution Bulk inserts
:grouped Returns rows grouped by a key JOIN queries with parent-child nesting
-- @name ListActiveUsers
-- @returns :many
SELECT id, name, email FROM users WHERE status = 'active';
-- @name DeleteUser
-- @returns :exec
DELETE FROM users WHERE id = $1;

Specifies which table’s columns become the parent struct when using @returns :grouped. All other selected columns become children collected into a nested list.

Format: -- @group_by table.column

This annotation is required when @returns :grouped is used and produces an error if omitted.

-- @name GetUsersWithOrders
-- @returns :grouped
-- @group_by users.id
SELECT
u.id,
u.name,
u.email,
o.id AS order_id,
o.total,
o.created_at AS order_date
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.status = 'active';

This generates a parent struct containing the users columns (id, name, email) with a nested collection of child structs containing the orders columns (order_id, total, order_date). The exact shape depends on the backend language – for example, Rust generates a Vec<ChildRow> field, Python generates a list[ChildRow] field, and so on.

Marks a query parameter as optional. Scythe rewrites the SQL at generation time so that passing NULL for the parameter skips the filter condition entirely.

-- @name ListUsers
-- @returns :many
-- @optional status
SELECT id, name, email FROM users WHERE status = $1;

Scythe rewrites WHERE status = $1 into WHERE ($1 IS NULL OR status = $1). At runtime, passing NULL returns all rows; passing a value filters normally.

@optional works with the following comparison operators:

Operator Rewritten form
= ($1 IS NULL OR col = $1)
<> ($1 IS NULL OR col <> $1)
!= ($1 IS NULL OR col != $1)
> ($1 IS NULL OR col > $1)
< ($1 IS NULL OR col < $1)
>= ($1 IS NULL OR col >= $1)
<= ($1 IS NULL OR col <= $1)
LIKE ($1 IS NULL OR col LIKE $1)
ILIKE ($1 IS NULL OR col ILIKE $1)
-- @name SearchUsers
-- @returns :many
-- @optional status
-- @optional name_pattern
SELECT id, name, email FROM users
WHERE status = $1 AND name ILIKE $2;

Parameter names in @optional are validated against the query. If the name does not match any parameter, scythe produces an error – catching typos at generation time rather than at runtime.

Documents a query parameter. Does not affect code generation, but adds documentation to generated code.

-- @name GetUser
-- @returns :one
-- @param id: the user's unique identifier
-- @param status: filter by account status
SELECT id, name FROM users WHERE id = $1 AND status = $2;

Format: -- @param <name>: <description> or -- @param <name> (without description).

A second, distinct @param form binds a 1-based position to a name and, unlike the docs-only form above, changes generated code: it renames the generated parameter from the pN fallback the analyzer would otherwise infer.

-- @name GetUser
-- @returns :one
-- @param $1 user_id: the user's unique identifier
SELECT id, name FROM users WHERE id = $1;

Format: -- @param $N name[: description]. The $N token must come first; anything else after @param is parsed as the docs-only form.

Forces specific columns to be nullable in generated code, overriding the inferred nullability.

-- @name GetUserProfile
-- @returns :one
-- @nullable bio, avatar_url
SELECT id, name, bio, avatar_url FROM users WHERE id = $1;

Accepts a comma-separated list of column names.

Forces specific columns to be non-nullable in generated code, overriding the inferred nullability.

-- @name GetUserWithDefaults
-- @returns :one
-- @nonnull status
SELECT id, name, COALESCE(status, 'active') AS status FROM users WHERE id = $1;

Useful when you know a column cannot be null due to application logic that the analyzer cannot infer.

Maps a column to a typed JSON struct instead of a generic JSON value.

-- @name GetEvent
-- @returns :one
-- @json data = EventData
SELECT id, data FROM events WHERE id = $1;

Format: -- @json <column> = <TypeName>. The generated code will use Json<EventData> (Rust) or equivalent typed wrapper instead of a raw JSON value.

Marks a query as deprecated. The generated code will include deprecation annotations in languages that support them.

-- @name GetUserV1
-- @returns :one
-- @deprecated Use GetUserV2 instead
SELECT id, name FROM users WHERE id = $1;

In Rust, this generates #[deprecated(note = "Use GetUserV2 instead")] on the function.

-- @name GetOrderDetails
-- @returns :one
-- @param order_id: the order to look up
-- @optional status
-- @nullable discount_code
-- @nonnull total
-- @json metadata = OrderMetadata
-- @deprecated Use GetOrderDetailsV2 for new code
SELECT
o.id,
o.total,
o.discount_code,
o.metadata,
u.name AS customer_name
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.id = $1;

An annotation scythe does not recognize (-- @foo bar) is not a parse error. It is captured verbatim as a custom annotation and exposed to crate consumers who want to layer their own annotation vocabulary on top of scythe. This means a typo in a known annotation name (@nullible instead of @nullable) is silently accepted rather than rejected – double-check spelling manually.

Annotation keywords are case-insensitive. These are equivalent:

-- @name GetUser
-- @Name GetUser
-- @NAME GetUser

The annotation value (e.g., the query name) preserves its original casing.