What Skolem actually proves
Given a schema S, two queries
Q₁ and Q₂,
and a bound k, Skolem asks Z3 for a
database that satisfies S, holds at
most k rows per table, and makes the
two output bags differ.
divergent is absolute: a counterexample
is a counterexample at any scale. equivalent
is bounded — see bounds & completeness. Skolem
runs z3 5.1.0.
Quickstart
Every surface talks to the same engine. Pick whichever fits where the query lives. You'll need an API key from the API Keys page first.
HTTP
curl -X POST https://sqlverify.com/api/verify/text \
-H "Authorization: Bearer $SKOLEM_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"ddl_sql": "CREATE TABLE users (id INT PRIMARY KEY, org_id INT);",
"sql_v1": "SELECT id FROM users",
"sql_v2": "SELECT id FROM users WHERE org_id IS NOT NULL"
}'
CLI
export SKOLEM_API_KEY=skm_your_key_here skolem verify --ddl schema.sql --v1 before.sql --v2 after.sql # or verify every query your branch changed: skolem diff --base origin/main --ddl migrations/ 'queries/*.sql'
AI agents (MCP)
Expose verification as a tool to Claude Code, Claude Desktop, or Cursor so an agent can check its own SQL rewrites in-loop and revise against a real counterexample instead of guessing.
Setup instructions →API reference
Authentication
Send your key as Authorization: Bearer skm_…
or X-API-Key: skm_…. Keys are shown
once at creation and stored only as a hash — if you lose one, revoke it
and mint another.
Request body
| Field | Type | Default | Description |
|---|---|---|---|
| ddl_sql | string | required | Flyway-style CREATE TABLE DDL defining the schema both queries run against. ALTER TABLE statements are folded in statement order. |
| sql_v1 | string | required | The original / trusted SELECT query. |
| sql_v2 | string | required | The rewritten query to prove equivalent to sql_v1. |
| dialect | string | generic | Parser dialect hint. Both queries are read as the same dialect — cross-dialect comparison is out of scope. |
| bound | integer | 3 | Maximum rows per table Z3 explores. Higher catches rarer bugs and costs solve time. Values above 6 are rejected. |
| timeout_ms | integer | 60000 | Solver budget in milliseconds. Clamped to 120,000. On timeout the status is unknown — never a wrong verdict. |
| project_id | string | null | null | Optional project UUID to tag the run with. Silently dropped if the project isn't yours. |
Response body
| Field | Type | Description |
|---|---|---|
| status | string | equivalent, divergent, unknown, or error. See the table below. |
| divergence_reason | string | null | Short summary of how the two queries differ. Present when status is divergent. |
| counterexample_db | object | null | A concrete database — table name to rows — on which the two queries disagree. This is the proof, and it is replayable. |
| query_v1_output | array | null | Rows sql_v1 returns when run on counterexample_db. |
| query_v2_output | array | null | Rows sql_v2 returns when run on counterexample_db. |
| error_message | string | null | Why the input was rejected. Present when status is error. |
| explanation | string | null | Prose explanation of the divergence, written by the configured LLM. Populated automatically for divergent results. |
| policy_fail_on | string | null |
Status values
| equivalent | Proven: no database within the bound distinguishes the two queries. |
| divergent | The queries disagree. counterexample_db holds a database that proves it, and both outputs are replayed on it. |
| unknown | The solver ran out of time. Not a proof of equivalence — it means we don't know. |
| error | The input used SQL outside the supported subset, or the DDL didn't parse. Skolem fails closed rather than silently ignoring what it can't encode. |
Example: a divergent result
The quickstart request above returns this — the rewrite silently drops
users whose org_id is NULL:
{
"status": "divergent",
"divergence_reason": "A row returned by sql_v1 is absent from sql_v2",
"counterexample_db": {
"users": [{ "id": 1, "org_id": null }]
},
"query_v1_output": [{ "id": 1 }],
"query_v2_output": [],
"error_message": null,
"explanation": "sql_v2 adds `WHERE org_id IS NOT NULL`. Under three-valued
logic `NULL IS NOT NULL` is false, so the row is filtered out …"
}
Error responses
| 401 | Missing, malformed, or revoked API key. |
| 402 | Free-tier monthly run limit reached. See pricing. |
| 422 | Body failed validation — a missing field, or a bound above 6. |
| 429 | Per-IP rate limit exceeded. Back off and retry. |
Note that a divergent verdict is still
an HTTP 200 — the request succeeded, the queries just
aren't equivalent. Only the codes above indicate a failed call.
Coverage
What the encoder handles today. Anything in the
rejected column returns
error — never a guess, and never a
silently dropped clause.
| Feature | Status | Notes |
|---|---|---|
| SELECT / projection | encoded | column lists and aliases |
| WHERE / HAVING | encoded | full AND / OR / NOT trees, three-valued |
| INNER · LEFT · RIGHT · FULL | encoded | left-deep chains, single-column equality ON |
| NULL semantics | encoded | every cell is (is_null, value) |
| GROUP BY + HAVING | encoded | keys may come from any joined table |
| COUNT(*) · COUNT(col) · SUM | encoded | COUNT(*) vs COUNT(col) distinguished |
| Bag multiplicity | encoded | duplicates are part of the answer |
| PK · FK · NOT NULL | encoded | constrain the counterexample |
| Non-recursive CTEs | partial | materialized; fails closed on an outer-join side |
| IN (SELECT …) | partial | uncorrelated, single-column body only |
| TEXT / TIMESTAMP | partial | equality only — ordering is rejected |
| ORDER BY | partial | parsed then ignored (bag semantics) |
| UNION · DISTINCT · LIMIT | rejected | not encodable yet |
| Window functions | rejected | frames not encodable |
| EXISTS · correlated subqueries | rejected | outer-alias references |
| MIN · MAX · AVG · LIKE · BETWEEN | rejected | no string/ordering theory in the encoding |
| CROSS JOIN · self-joins | rejected | outside the left-deep chain model |
Bag semantics
SQL returns bags, not sets. Duplicate multiplicity is part of the answer,
so the encoder compares row counts per distinct tuple — not membership.
Two queries returning the same rows in different multiplicities are
divergent.
{(1), (1), (2)} = {(1), (2)}
-- would call these equal
count(1)=2 vs count(1)=1
-- divergent: a JOIN duplicated a row
ORDER BY is accepted and ignored —
equivalence is judged on the bag, not the ordering.
SELECT DISTINCT is
rejected, not silently treated as a set.
Three-valued logic
Every column is encoded as a value plus an is-null flag, and predicates
evaluate to true / false / unknown. A WHERE
clause keeps a row only on true — this is where most LLM
rewrites break.
| Expression (cents IS NULL) | Evaluates to | Row survives WHERE |
|---|---|---|
| cents > 100 | unknown | no |
| NOT (cents > 100) | unknown | no |
| cents IS NULL | true | yes |
| cents IS NOT NULL | false | no |
| COUNT(cents) | skips NULL rows | n/a |
| COUNT(*) | counts NULL-extended rows | n/a |
The second row is the classic trap: negating a predicate does
not recover the rows it filtered out, because
NOT unknown is still
unknown.
Bounds & completeness
Verification is bounded: the solver searches databases of at most
k rows per table. This is the honest limit of the tool, and the reason an
equivalent verdict is always reported
with its bound attached.
| Bound | Typically catches |
|---|---|
| 1 | projection and predicate mistakes |
| 2 | duplicate or lost rows from a JOIN change |
| 3 | grouping and NULL-extension bugs — the default |
| 6 | multi-table and dedup edge cases; the ceiling |
In practice small-model bias does the work: join,
grouping and NULL bugs almost always have a two- or three-row witness.
Cost grows roughly boundtables joined,
so a raised bound on a deep chain can exhaust the timeout and return
unknown — which is not a pass.
The integer value domain is also finite, widened automatically to cover
every numeric literal in the two queries. An
equivalent verdict is sound within
that window.
CLI flags
$ skolem verify --ddl schema.sql --v1 before.sql --v2 after.sql $ skolem diff --base origin/main --ddl migrations/ 'queries/*.sql'
| --ddl PATH | schema file. For diff, also a directory or glob — a directory concatenates in numeric Flyway V<n>__ order. |
| --v1 / --v2 PATH | verify only. Either may be - for stdin (at most one). |
| --base REF | diff only. Compares the working tree against git merge-base REF HEAD. |
| --bound K | rows per table, 1–6 (default 3) |
| --timeout-ms MS | solver budget, clamped to 120,000 |
| --dialect NAME | generic | postgres | mysql | sqlite |
| --fail-on LIST | which statuses fail the gate (default divergent) |
| --output MODE | auto | human | json | github |
| --project ID | tag the run with a project |
| --url / --api-key | override SKOLEM_URL / SKOLEM_API_KEY |
Exit codes and pipeline wiring are covered on integrations.
Limits
boundcaps at 6 rows per table.timeout_msdefaults to 60,000 and is clamped to 120,000.- Uploaded SQL files cap at 512 KB each.
- An
equivalentverdict is sound within the bound — it rules out every counterexample up to that many rows per table, which covers the overwhelming majority of real rewrite bugs.