reference / semantics

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.

sat → a witness exists → divergent (the queries are not equivalent, full stop)
unsat → no witness at bound k → equivalent under S, for databases of that size
unknown → the solver gave up → no claim either way

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

POST /api/verify/text

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

FieldTypeDefaultDescription
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

FieldTypeDescription
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

401Missing, malformed, or revoked API key.
402Free-tier monthly run limit reached. See pricing.
422Body failed validation — a missing field, or a bound above 6.
429Per-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.

FeatureStatusNotes
SELECT / projectionencodedcolumn lists and aliases
WHERE / HAVINGencodedfull AND / OR / NOT trees, three-valued
INNER · LEFT · RIGHT · FULLencodedleft-deep chains, single-column equality ON
NULL semanticsencodedevery cell is (is_null, value)
GROUP BY + HAVINGencodedkeys may come from any joined table
COUNT(*) · COUNT(col) · SUMencodedCOUNT(*) vs COUNT(col) distinguished
Bag multiplicityencodedduplicates are part of the answer
PK · FK · NOT NULLencodedconstrain the counterexample
Non-recursive CTEspartialmaterialized; fails closed on an outer-join side
IN (SELECT …)partialuncorrelated, single-column body only
TEXT / TIMESTAMPpartialequality only — ordering is rejected
ORDER BYpartialparsed then ignored (bag semantics)
UNION · DISTINCT · LIMITrejectednot encodable yet
Window functionsrejectedframes not encodable
EXISTS · correlated subqueriesrejectedouter-alias references
MIN · MAX · AVG · LIKE · BETWEENrejectedno string/ordering theory in the encoding
CROSS JOIN · self-joinsrejectedoutside 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.

set thinking (wrong)
{(1), (1), (2)} = {(1), (2)}
-- would call these equal
bag thinking (Skolem)
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 toRow survives WHERE
cents > 100unknownno
NOT (cents > 100)unknownno
cents IS NULLtrueyes
cents IS NOT NULLfalseno
COUNT(cents)skips NULL rowsn/a
COUNT(*)counts NULL-extended rowsn/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.

BoundTypically catches
1projection and predicate mistakes
2duplicate or lost rows from a JOIN change
3grouping and NULL-extension bugs — the default
6multi-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 PATHschema file. For diff, also a directory or glob — a directory concatenates in numeric Flyway V<n>__ order.
--v1 / --v2 PATHverify only. Either may be - for stdin (at most one).
--base REFdiff only. Compares the working tree against git merge-base REF HEAD.
--bound Krows per table, 1–6 (default 3)
--timeout-ms MSsolver budget, clamped to 120,000
--dialect NAMEgeneric | postgres | mysql | sqlite
--fail-on LISTwhich statuses fail the gate (default divergent)
--output MODEauto | human | json | github
--project IDtag the run with a project
--url / --api-keyoverride SKOLEM_URL / SKOLEM_API_KEY

Exit codes and pipeline wiring are covered on integrations.

Limits

  • bound caps at 6 rows per table.
  • timeout_ms defaults to 60,000 and is clamped to 120,000.
  • Uploaded SQL files cap at 512 KB each.
  • An equivalent verdict 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.