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.
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.
| Library | Version | Dialect | CGo |
|---|---|---|---|
| GoSQLX | commit c95aa7c719d0 | multi-dialect | no |
| pg_query_go | v6.2.5 | PostgreSQL | yes |
| Vitess sqlparser | v0.24.5 | MySQL | no |
| TiDB parser | v0.0.0-…7193401b | MySQL (TiDB) | no |
| xwb1989/sqlparser | v0.0.0-…12038786 | MySQL | no |
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.
| Query | SQL |
|---|---|
| Simple SELECT | SELECT id, name FROM users |
| SELECT with WHERE, ORDER BY, LIMIT | SELECT id, name, email FROM users WHERE active = 1 AND created_at > '2024-01-01' ORDER BY name LIMIT 10 |
| Two-table JOIN | SELECT 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 HAVING | SELECT status, COUNT(*), SUM(total) FROM orders GROUP BY status HAVING COUNT(*) > 10 |
| IN subquery | SELECT name FROM users WHERE id IN (SELECT user_id FROM orders WHERE total > 1000) |
| Common table expression | WITH recent AS (SELECT user_id, total FROM orders WHERE total > 100) SELECT user_id, SUM(total) FROM recent GROUP BY user_id |
| Window function | SELECT name, salary, ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) FROM employees |
| Multi-JOIN analytical query | SELECT 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 VALUES | INSERT INTO users (id, name, email) VALUES (1, 'alice', 'alice@example.com') |
| UPDATE with WHERE | UPDATE users SET name = 'bob', active = 0 WHERE id = 42 |
| DELETE with WHERE | DELETE 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.
| Query | GoSQLX | pg_query_go | Vitess sqlparser | TiDB parser | xwb1989/sqlparser |
|---|---|---|---|---|---|
| Simple SELECT | 925 | 7,107 | 870 | 1,464 | 3,441 |
| SELECT with WHERE, ORDER BY, LIMIT | 2,028 | 22,686 | 2,035 | 3,894 | 5,803 |
| Two-table JOIN | 3,215 | 30,035 | 2,694 | 4,850 | 11,725 |
| GROUP BY with HAVING | 2,230 | 17,666 | 1,778 | 3,417 | 5,897 |
| IN subquery | 1,887 | 15,955 | 2,176 | 3,362 | 9,678 |
| Common table expression | 2,748 | 23,177 | 2,517 | 4,918 | rejected |
| Window function | 1,899 | 14,279 | 2,071 | 3,221 | rejected |
| Multi-JOIN analytical query | 8,776 | 81,637 | 6,989 | 12,445 | 21,260 |
| INSERT VALUES | 1,443 | 8,918 | 1,249 | 2,105 | 4,209 |
| UPDATE with WHERE | 1,200 | 9,423 | 1,158 | 2,127 | 4,063 |
| DELETE with WHERE | 841 | 5,678 | 755 | 1,223 | 3,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.
| Library | CGo | Queries compared | Queries rejected | Faster than GoSQLX on | Time relative to GoSQLX (geometric mean) |
|---|---|---|---|---|---|
| pg_query_go | yes | 11 | 0 | 0 of 11 | 8.14x |
| Vitess sqlparser | no | 11 | 0 | 8 of 11 | 0.93x |
| TiDB parser | no | 11 | 0 | 0 of 11 | 1.62x |
| xwb1989/sqlparser | no | 9 | 2 | 0 of 9 | 3.30x |
Allocations per parse
Median heap allocations per operation. Lower is better.
| Query | GoSQLX | pg_query_go | Vitess sqlparser | TiDB parser | xwb1989/sqlparser |
|---|---|---|---|---|---|
| Simple SELECT | 7 | 36 | 14 | 24 | 26 |
| SELECT with WHERE, ORDER BY, LIMIT | 21 | 111 | 30 | 50 | 69 |
| Two-table JOIN | 20 | 145 | 33 | 54 | 97 |
| GROUP BY with HAVING | 21 | 86 | 25 | 45 | 61 |
| IN subquery | 16 | 75 | 28 | 44 | 59 |
| Common table expression | 24 | 112 | 37 | 64 | rejected |
| Window function | 14 | 70 | 25 | 44 | rejected |
| Multi-JOIN analytical query | 56 | 398 | 73 | 124 | 253 |
| INSERT VALUES | 19 | 51 | 17 | 34 | 37 |
| UPDATE with WHERE | 15 | 50 | 19 | 26 | 40 |
| DELETE with WHERE | 7 | 30 | 11 | 16 | 26 |
Bytes allocated per parse
Median bytes allocated per operation. Lower is better.
| Query | GoSQLX | pg_query_go | Vitess sqlparser | TiDB parser | xwb1989/sqlparser |
|---|---|---|---|---|---|
| Simple SELECT | 2,229 | 1,816 | 840 | 2,296 | 20,008 |
| SELECT with WHERE, ORDER BY, LIMIT | 3,945 | 5,024 | 1,528 | 6,032 | 21,128 |
| Two-table JOIN | 6,063 | 6,624 | 1,776 | 6,416 | 62,480 |
| GROUP BY with HAVING | 4,193 | 4,072 | 1,240 | 5,152 | 20,960 |
| IN subquery | 3,608 | 3,816 | 4,201 | 5,224 | 61,896 |
| Common table expression | 4,940 | 5,472 | 1,896 | 6,936 | rejected |
| Window function | 3,248 | 3,440 | 3,977 | 4,688 | rejected |
| Multi-JOIN analytical query | 17,697 | 17,512 | 6,402 | 16,048 | 66,120 |
| INSERT VALUES | 2,556 | 2,432 | 952 | 2,832 | 20,176 |
| UPDATE with WHERE | 2,165 | 2,208 | 920 | 3,032 | 20,208 |
| DELETE with WHERE | 1,764 | 1,416 | 704 | 1,840 | 19,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:publishThe 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:
- Benchmarks page: GoSQLX parse-stage numbers and the competitor comparison
- Compare Go SQL parsers: features, dialects and trade-offs beyond speed
- Performance tuning