Skip to main content

Parsing SQL in Go: benchmark methodology and results

A reproducible end-to-end benchmark of five Go SQL parsers, GoSQLX, pg_query_go, Vitess, TiDB and xwb1989, with the method, raw data and honest caveats.

9 min read

Benchmark claims about SQL parsers are easy to make and hard to check. Numbers get copied from old READMEs, measured on different machines, or compare a tokenizer on one side with a full parse on the other. This post describes the benchmark harness in the GoSQLX repository, which anyone can rerun, and the results of its most recent recorded run.

Every figure below is generated at build time from the harness output committed to the repository (benchmarks/compare/results/latest.json). None of them is typed by hand, and when the harness is rerun and the file is updated, the tables on this page change with it.

What is measured

The harness measures the end-to-end cost of turning SQL text into each library's syntax tree: the input is a Go string, the output is a usable AST. For GoSQLX that is gosqlx.Parse (tokenize and parse) followed by ast.ReleaseAST. For the other libraries it is their standard parse entry point.

This run: Apple M4 (darwin/arm64, 10 CPUs), go1.27.2, GoSQLX commit c95aa7c719d0, run on 2026-10-15; median of 10 runs, end-to-end stage (SQL text in, AST out).

Libraries

Versions are pinned in the harness's own go.mod. GoSQLX is resolved from the enclosing repository through a replace directive, so a run always measures the checked-out commit.

LibraryVersionDialectCGo
GoSQLXcommit c95aa7c719d0multi-dialectno
pg_query_gov6.2.5PostgreSQLyes
Vitess sqlparserv0.24.5MySQLno
TiDB parserv0.0.0-…7193401bMySQL (TiDB)no
xwb1989/sqlparserv0.0.0-…12038786MySQLno

Modules: GoSQLX: github.com/ajitpratap0/GoSQLX; pg_query_go: github.com/pganalyze/pg_query_go/v6; Vitess sqlparser: vitess.io/vitess; TiDB parser: github.com/pingcap/tidb/pkg/parser; xwb1989/sqlparser: github.com/xwb1989/sqlparser. Pseudo-versions are shortened here; the full versions are in the harness's go.mod.

Queries

Every library parses the same statements. The corpus sticks to a portable subset of SQL (no quoted identifiers, no dialect-only operators) so that MySQL-only and PostgreSQL-only parsers can take part. When a library rejects a statement, the harness records it as unsupported instead of benchmarking an error path.

QuerySQL
Simple SELECTSELECT id, name FROM users
SELECT with WHERE, ORDER BY, LIMITSELECT id, name, email FROM users WHERE active = 1 AND created_at > '2024-01-01' ORDER BY name LIMIT 10
Two-table JOINSELECT u.id, u.name, o.total FROM users u JOIN orders o ON u.id = o.user_id WHERE o.total > 100 ORDER BY o.total DESC LIMIT 50
GROUP BY with HAVINGSELECT status, COUNT(*), SUM(total) FROM orders GROUP BY status HAVING COUNT(*) > 10
IN subquerySELECT name FROM users WHERE id IN (SELECT user_id FROM orders WHERE total > 1000)
Common table expressionWITH recent AS (SELECT user_id, total FROM orders WHERE total > 100) SELECT user_id, SUM(total) FROM recent GROUP BY user_id
Window functionSELECT name, salary, ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) FROM employees
Multi-JOIN analytical querySELECT c.name, p.category, COUNT(o.id), SUM(oi.quantity * oi.price) FROM customers c JOIN orders o ON c.id = o.customer_id JOIN order_items oi ON o.id = oi.order_id LEFT JOIN products p ON oi.product_id = p.id WHERE o.created_at >= '2024-01-01' AND p.category IS NOT NULL GROUP BY c.name, p.category HAVING SUM(oi.quantity * oi.price) > 500 ORDER BY c.name, p.category LIMIT 100
INSERT VALUESINSERT INTO users (id, name, email) VALUES (1, 'alice', 'alice@example.com')
UPDATE with WHEREUPDATE users SET name = 'bob', active = 0 WHERE id = 42
DELETE with WHEREDELETE FROM sessions WHERE expires_at < '2024-01-01'

Method

Each benchmark runs with go test -bench -benchmem and -count=10, and the harness records the median of the ten samples for time, bytes and allocations. The command was:

go test -run=^$ -bench=^BenchmarkParse$ -benchmem -count=10 .

The raw go test output is committed next to the JSON (results/latest.txt), so you can summarise it differently, for example with benchstat, if you disagree with the median.

Results

Time per parse

Median nanoseconds per operation, rounded to the nearest nanosecond. Lower is better. "rejected" means the library could not parse that statement.

QueryGoSQLXpg_query_goVitess sqlparserTiDB parserxwb1989/sqlparser
Simple SELECT9257,1078701,4643,441
SELECT with WHERE, ORDER BY, LIMIT2,02822,6862,0353,8945,803
Two-table JOIN3,21530,0352,6944,85011,725
GROUP BY with HAVING2,23017,6661,7783,4175,897
IN subquery1,88715,9552,1763,3629,678
Common table expression2,74823,1772,5174,918rejected
Window function1,89914,2792,0713,221rejected
Multi-JOIN analytical query8,77681,6376,98912,44521,260
INSERT VALUES1,4438,9181,2492,1054,209
UPDATE with WHERE1,2009,4231,1582,1274,063
DELETE with WHERE8415,6787551,2233,078

Summary against GoSQLX

For each library, over the queries both it and GoSQLX accept: how many queries it parsed faster than GoSQLX, and the geometric mean of its time divided by GoSQLX's time. A value below 1.00x means faster than GoSQLX on average; above 1.00x means slower.

LibraryCGoQueries comparedQueries rejectedFaster than GoSQLX onTime relative to GoSQLX (geometric mean)
pg_query_goyes1100 of 118.14x
Vitess sqlparserno1108 of 110.93x
TiDB parserno1100 of 111.62x
xwb1989/sqlparserno920 of 93.30x

Allocations per parse

Median heap allocations per operation. Lower is better.

QueryGoSQLXpg_query_goVitess sqlparserTiDB parserxwb1989/sqlparser
Simple SELECT736142426
SELECT with WHERE, ORDER BY, LIMIT21111305069
Two-table JOIN20145335497
GROUP BY with HAVING2186254561
IN subquery1675284459
Common table expression241123764rejected
Window function14702544rejected
Multi-JOIN analytical query5639873124253
INSERT VALUES1951173437
UPDATE with WHERE1550192640
DELETE with WHERE730111626

Bytes allocated per parse

Median bytes allocated per operation. Lower is better.

QueryGoSQLXpg_query_goVitess sqlparserTiDB parserxwb1989/sqlparser
Simple SELECT2,2291,8168402,29620,008
SELECT with WHERE, ORDER BY, LIMIT3,9455,0241,5286,03221,128
Two-table JOIN6,0636,6241,7766,41662,480
GROUP BY with HAVING4,1934,0721,2405,15220,960
IN subquery3,6083,8164,2015,22461,896
Common table expression4,9405,4721,8966,936rejected
Window function3,2483,4403,9774,688rejected
Multi-JOIN analytical query17,69717,5126,40216,04866,120
INSERT VALUES2,5562,4329522,83220,176
UPDATE with WHERE2,1652,2089203,03220,208
DELETE with WHERE1,7641,4167041,84019,928

Reading the results

The tables above are regenerated on every build. The observations in this section describe the run recorded on this page; if you are reading a later version of the data, trust the tables over the prose.

GoSQLX is not the fastest parser on every query. Vitess, the MySQL parser from the Vitess project, is a strong choice for MySQL. In this run Vitess sqlparser is faster than GoSQLX on 8 of 11 queries compared, and its geometric-mean time is 0.93x GoSQLX's, so it is faster on most queries and on average (see the summary table). If you only parse MySQL and only need a syntax tree, Vitess is an excellent choice; GoSQLX's case is breadth across dialects and a built-in toolchain, not raw MySQL parse speed.

Allocation counts and allocated bytes tell different stories. In this run GoSQLX makes fewer heap allocations than the other libraries on most queries, which reflects its pooled tokenizer and zero-copy token values. Vitess allocates fewer bytes on most queries. Fewer allocations usually means less GC bookkeeping, fewer bytes means less memory traffic; which one matters more depends on your workload, so the harness reports both.

pg_query_go pays for exactness. pg_query_go embeds the real PostgreSQL parser through CGo and converts its tree into Go structs. That conversion, and the CGo boundary, make it the slowest library in every row. In exchange it accepts exactly what PostgreSQL accepts, which no pure-Go parser can promise. If PostgreSQL fidelity matters more than speed, it is the right tool.

Coverage is part of the result. xwb1989/sqlparser, an unmaintained 2018 fork of the Vitess parser, rejects the CTE and window-function statements. A parser that cannot read your query has no meaningful speed.

What this benchmark does not tell you

  • It is not identical work. The libraries implement different SQL dialects and build different ASTs (pg_query_go wraps the real PostgreSQL parser through CGo and converts its tree to Go structs; Vitess, TiDB and xwb1989 implement MySQL grammars). Each figure is the end-to-end cost of turning the same SQL text into that library's AST on the stated hardware, not identical work.
  • It is one machine. Every figure belongs to the hardware and Go version listed above. Shared CI runners are noisy; run the harness on the machine you care about.
  • It is single-threaded. It does not measure throughput under concurrency, contention on pools, or GC behaviour in a long-running service.
  • It is end-to-end only. It does not separate tokenizing from parsing. GoSQLX's own stage-level benchmarks live in the main repository and are reported separately on the benchmarks page, labelled as parse-stage numbers.
  • It does not measure correctness. Accepting a statement is not the same as building the right tree for it. Use dialect test suites for that.

Reproduce it

You need Go and a C toolchain (pg_query_go uses CGo).

git clone https://github.com/ajitpratap0/GoSQLX.git
cd GoSQLX/benchmarks/compare

# one quick pass over the corpus
go test -run='^$' -bench=. -benchmem ./...

# the full run used for publishing: -count=10, writes results/latest.json and latest.txt
task bench:publish

The corpus is in benchmarks/compare/corpus.go and the adapters for each library are in parsers.go. If you think an adapter is unfair to a library, for example because it uses a slower entry point than necessary, please open an issue or a pull request: the point of a public harness is that its mistakes can be found and fixed.

Further reading: