There are two ways to get SQL queries as Go code. The first is sqlc: SQL is written in files, the tool reads them, validates them against the database schema and generates type-safe functions. The second path is older and more universal: templates. You write a query as a template with parameters, a template compiler turns it into a Go function that assembles the finished SQL. The best-known such tool is qtc from the quicktemplate package, by the same author as fasthttp and zerolog.
Let's see what qtc actually generates, how it differs from sqlc, and which pitfalls await a team that decides to build its own codegen on top of templates.
What qtc generates
quicktemplate is a general-purpose template engine: it compiles *.qtpl files into plain Go code where text is assembled through strings.Builder — fast, without intermediate allocations. It was originally taken for HTML, but a template knows nothing about the target language, so the template can be an SQL query:
{% func InsertOrder(id string, total int) %}
INSERT INTO orders (id, total)
VALUES ('{%s id %}', {%d total %})
{% endfunc %}
After running qtc, a generated Go file appears next to it, and the query is assembled by calling a function — in the same chained style familiar from zerolog and fasthttp by this author:
// generated by qtc, do not edit by hand
query := InsertOrderSQL("a-1024", 1250)
// query: "INSERT INTO orders (id, total) VALUES ('a-1024', 1250)"
Such codegen exists in real projects: .sql.tmpl templates with a custom generator on top are not rare in companies where queries are complex and multi-part, and sqlc's "one annotation — one query" model is inconvenient.
How it differs from sqlc
The difference is fundamental, and it is the root of all pitfalls. sqlc knows the database schema: it parses SQL, checks it against migrations and generates not a string but a function with typed arguments and a typed result. A typo in a column name is a sqlc generate error, before compilation and before production.
qtc knows nothing about the database. It turns a template into a function that returns a string. Until that string reaches PostgreSQL, nobody checks whether the orders table exists or whether the column is called total. The error surfaces at runtime — at best in tests, at worst in production:
// qtc generated exactly what the template says — nobody validates it
query := ListOrdersByStatusSQL("NEW")
rows, err := pool.Query(ctx, query) // fails here if the column was renamed
So the rule is: your own template-based codegen must compensate for the lost validation. The compensation is integration tests against the real schema — for example via testcontainers: every generated query is run against a real PostgreSQL in CI. Without that, template codegen generates bugs instead of protection from them.
Under the hood: strings.Builder
The point of qtc is not magic but string assembly speed. The idea itself can be shown with the standard library — this example runs by itself:
live example
package main
import (
"fmt"
"strings"
)
func main() {
var b strings.Builder
b.WriteString("INSERT INTO orders (id, total) VALUES ('")
b.WriteString("a-1024")
b.WriteString("', ")
b.WriteString(fmt.Sprint(1250))
b.WriteString(")")
fmt.Println(b.String())
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
This is exactly the code qtc generates — only neater and with {% for %} loops unrolled into Go loops. Understanding this removes the fear of "our own generator": it is the same strings.Builder, assembled in advance.
Pitfalls of your own codegen
Generated code in the repository. If qtc is run locally and the result is committed, reviews get diffs nobody reads but that break the build when they drift from the template. The accepted approach is to run generation via go:generate and check in CI that the committed code matches the freshly generated one: go generate ./... && git diff --exit-code.
Injection through concatenation. A template assembles SQL as a string — if a user value is substituted into the template directly, you get a SQL injection. Values always go through placeholders $1, $2, and substitution into the template is only for table and column names from a whitelist:
{# do: values as placeholders, substitution only for a whitelist #}
{% func SelectBy(table string, limit int) %}
SELECT * FROM {%s table %} ORDER BY created_at DESC LIMIT {%d limit %}
{% endfunc %}
Migrations and templates drift apart. A column is renamed in a migration, the template stays old — sqlc would catch it at generation time, qtc will not. The only defense is tests against the real schema in CI.
Short version
- qtc compiles
*.qtpltemplates into Go functions that assemble SQL viastrings.Builder— fast and allocation-free. - Unlike sqlc, qtc does not validate queries against the schema — the string reaches the database as is, errors surface at runtime.
- Your own template codegen is justified for complex multi-part queries and must compensate for the lost validation with integration tests against the real schema.
- Pitfalls: committed code drifting from templates in CI, SQL injections through value substitution, drift from migrations.
- Order of choice: queries in
*.sqlunderdb/queries/— sqlc; complex query assembly — templates + testcontainers.
What to read next
- Persistence in Go: sqlc and pgx — the main path: type-safe queries validated against the schema.
- sqlc codegen and query files — the R-SQLC-QF rules: annotations, overrides, nullable.
- Integration testing in Go — how to test against a real database via testcontainers.