English
Optional sqlc Support
myCart has two SQL backend implementations:
- Raw SQL (default): Hand-written SQL queries with runtime parameter binding
- sqlc (optional): Compile-time type-safe queries generated from annotated SQL
Both backends support SQLite and PostgreSQL, pass the same test suite, and export identical public APIs. You choose at build time via Go build tags — the application binary uses one backend or the other, not both.
Which one?
| Raw SQL (default) | sqlc (optional) | |
|---|---|---|
| Setup | none | install sqlc, run code generator |
| Type safety | runtime (via database.Conn wrapper) | compile-time (generated Go types) |
| Query changes | edit .go files | edit .sql files, regenerate code |
| Build speed | fast (no generation step) | slower (generate → compile) |
| Compilation errors | if query syntax wrong at runtime | if query syntax wrong at generation time |
| Fits | quick iteration, prototype mode | strict type safety, production mode |
Both run the same migrations and use the same database.Conn abstraction. There is no reduced-feature mode.
Why use sqlc?
Compile-time type safety. sqlc parses your SQL queries and generates Go code with correct types:
sql
-- db/queries/postgres/customer.sql
-- name: GetCustomerByEmail :one
SELECT id, email, name, created
FROM customer
WHERE email = $1;sqlc generates:
go
// internal/queries_sqlc/sqlc/postgres/customer.sql.go
type GetCustomerByEmailRow struct {
ID string
Email string
Name string
Created int64
}
func (q *Queries) GetCustomerByEmail(ctx context.Context, email string) (GetCustomerByEmailRow, error) {
// ... generated implementation
}Benefits:
- Wrong column names fail at compile time, not runtime
- Typos in table names caught during generation
- Type mismatches (e.g., treating
int64asstring) rejected by compiler - IDE autocomplete for query result fields
- No reflection, no
interface{}casting
Cost:
- Must run
make sqlc-generateafter every query change - Slower build (generation + compilation vs just compilation)
- Two sets of query files to maintain (
.sqlfiles + generated.gofiles)
Installing sqlc
One command:
bash
make install-sqlcThis installs the sqlc CLI tool via go install. Verify:
bash
sqlc version
# v1.27.0 (or similar)Generating code
After modifying any .sql file in db/queries/postgres/ or db/queries/sqlite/:
bash
make sqlc-generateThis:
- Reads
sqlc.yamlconfiguration - Parses schema from
db/schema/(migrations) - Parses queries from
db/queries/postgres/*.sqlanddb/queries/sqlite/*.sql - Generates type-safe Go code:
internal/queries_sqlc/sqlc/postgres/*.go(PostgreSQL dialect)internal/queries_sqlc/sqlc/sqlite/*.go(SQLite dialect)
Verification mode (recommended):
bash
make sqlc-verifyThis runs sqlc generate and verifies the generated code compiles.
Quick mode (skip verification):
bash
make sqlcBuilding with sqlc
Build the sqlc-enabled backend:
bash
make build-sqlcThis runs:
make sqlc-generate(ensures code is up-to-date)go build -tags sqlc -o mycart-sqlc ./cmd
The resulting mycart-sqlc binary uses the generated queries instead of raw SQL.
Build both backends (useful for comparison):
bash
make build-bothProduces:
mycart(raw SQL backend)mycart-sqlc(sqlc backend)
Both binaries are functionally identical — same CLI, same API, same behavior.
Testing with sqlc
Test sqlc backend with SQLite
bash
make test-queries-sqlc-sqliteRuns:
bash
go test -tags sqlc ./internal/queries_sqlc/... -count=1 -raceTest sqlc backend with PostgreSQL
bash
make test-queries-sqlc-postgresRequires TEST_POSTGRES_DSN environment variable (see Running tests on PostgreSQL).
Runs:
bash
TEST_DB_DRIVER=postgres go test -tags sqlc ./internal/queries_sqlc/... -count=1 -race4-mode test matrix
Test all combinations (raw+sqlc × SQLite+PostgreSQL):
bash
make test-queries-allThis runs:
test-queries-raw-sqlite— raw SQL + SQLitetest-queries-raw-postgres— raw SQL + PostgreSQLtest-queries-sqlc-sqlite— sqlc + SQLitetest-queries-sqlc-postgres— sqlc + PostgreSQL
All four must pass before merging query changes.
Running tests on PostgreSQL
The PostgreSQL tests need a dedicated test server with CREATEDB, CREATEROLE and SUPERUSER privileges. Each test gets its own database, which is dropped after the test passes.
Never point this at a server with data you care about.
Local Docker test server
bash
docker compose -f docker/docker-compose.yml -f docker/docker-compose_dev.yml up -d pgtestdbThis starts postgres:17-alpine on port 5433 with:
- tmpfs data directory (nothing persisted)
- durability off (fast tests)
- Credentials:
postgres:password
Then set the environment variable:
bash
export TEST_POSTGRES_DSN='postgres://postgres:password@localhost:5433/postgres?sslmode=disable'
make test-queries-allRemote PostgreSQL (Supabase, RDS, etc.)
Only use a throwaway test database. The tests will create and drop databases.
bash
export TEST_POSTGRES_DSN='postgresql://postgres:your-password@your-host:5432/postgres?sslmode=require'
make test-queries-allURL-encode special characters in passwords:
- Space:
%20 @:%40#:%23
Example with space in password:
bash
# Wrong (will fail to parse):
postgresql://user:my password@host/db
# Correct:
postgresql://user:my%20password@host/dbSee .env.example for more examples.
Adding new queries
Step 1: Write annotated SQL
Create or modify a .sql file in db/queries/postgres/ and db/queries/sqlite/.
PostgreSQL example (db/queries/postgres/customer.sql):
sql
-- name: GetCustomerByEmail :one
SELECT id, email, name, created
FROM customer
WHERE email = $1;
-- name: ListCustomers :many
SELECT id, email, name, created
FROM customer
ORDER BY created DESC
LIMIT $1 OFFSET $2;
-- name: CreateCustomer :one
INSERT INTO customer (id, email, name, created)
VALUES ($1, $2, $3, $4)
RETURNING id, email, name, created;SQLite example (db/queries/sqlite/customer.sql):
sql
-- name: GetCustomerByEmail :one
SELECT id, email, name, created
FROM customer
WHERE email = ?;
-- name: ListCustomers :many
SELECT id, email, name, created
FROM customer
ORDER BY created DESC
LIMIT ? OFFSET ?;
-- name: CreateCustomer :one
INSERT INTO customer (id, email, name, created)
VALUES (?, ?, ?, ?)
RETURNING id, email, name, created;Query annotations:
:one— expects exactly one row (returns single struct):many— expects zero or more rows (returns slice):exec— no rows returned (returnssql.Result):execrows— returns rows affected count
Parameter placeholders:
- PostgreSQL:
$1,$2,$3, ... - SQLite:
?,?,?, ...
Step 2: Generate code
bash
make sqlc-generatesqlc produces:
internal/queries_sqlc/sqlc/postgres/customer.sql.gointernal/queries_sqlc/sqlc/sqlite/customer.sql.go
Step 3: Expose via backend interface
Edit internal/queries_sqlc/backend.go:
go
type backend interface {
// Customer methods
GetCustomerByEmail(ctx context.Context, email string) (*CustomerRow, error)
ListCustomers(ctx context.Context, limit, offset int) ([]*CustomerRow, error)
CreateCustomer(ctx context.Context, id, email, name string, created int64) (*CustomerRow, error)
}Step 4: Implement wrapper methods
The generated code uses sqlc-specific types. Wrap them to match the backend interface:
PostgreSQL (internal/queries_sqlc/backend_postgres.go):
go
func (b *postgresBackend) GetCustomerByEmail(ctx context.Context, email string) (*CustomerRow, error) {
row, err := b.queries.GetCustomerByEmail(ctx, email)
if err != nil {
return nil, err
}
return &CustomerRow{
ID: row.ID,
Email: row.Email,
Name: row.Name,
Created: row.Created,
}, nil
}SQLite (internal/queries_sqlc/backend_sqlite.go):
go
func (b *sqliteBackend) GetCustomerByEmail(ctx context.Context, email string) (*CustomerRow, error) {
row, err := b.queries.GetCustomerByEmail(ctx, email)
if err != nil {
return nil, err
}
return &CustomerRow{
ID: row.ID,
Email: row.Email,
Name: row.Name,
Created: row.Created,
}, nil
}Step 5: Test
Write tests in internal/queries_sqlc/*_test.go:
go
func TestGetCustomerByEmail(t *testing.T) {
t.Parallel()
ctx := context.Background()
// Arrange
customer := &CustomerRow{
ID: "c1",
Email: "[email protected]",
Name: "Test User",
Created: time.Now().Unix(),
}
// Act
created, err := CreateCustomer(ctx, customer.ID, customer.Email, customer.Name, customer.Created)
require.NoError(t, err)
retrieved, err := GetCustomerByEmail(ctx, customer.Email)
require.NoError(t, err)
// Assert
assert.Equal(t, customer.ID, retrieved.ID)
assert.Equal(t, customer.Email, retrieved.Email)
assert.Equal(t, customer.Name, retrieved.Name)
}Run all 4 modes:
bash
make test-queries-allConfiguration
sqlc reads sqlc.yaml in the repository root:
yaml
version: "2"
sql:
- engine: "postgresql"
schema: "db/schema" # migrations (CREATE TABLE statements)
queries: "db/queries/postgres" # annotated SQL queries
gen:
go:
package: "pggen"
out: "internal/queries_sqlc/sqlc/postgres"
sql_package: "pgx/v5" # PostgreSQL driver
emit_json_tags: true
emit_interface: true
emit_pointers_for_null_types: true
- engine: "sqlite"
schema: "db/schema"
queries: "db/queries/sqlite"
gen:
go:
package: "sqlitegen"
out: "internal/queries_sqlc/sqlc/sqlite"
emit_json_tags: true
emit_interface: true
emit_pointers_for_null_types: trueDon't modify this unless adding a new query group or changing generation options.
Directory structure
mycart/
├── sqlc.yaml # sqlc configuration
├── db/
│ ├── schema/ # migrations (CREATE TABLE, ...)
│ └── queries/
│ ├── postgres/*.sql # PostgreSQL-specific queries
│ └── sqlite/*.sql # SQLite-specific queries
└── internal/
├── queries/ # raw SQL backend (default)
│ ├── auth.go
│ ├── customer.go
│ └── ...
└── queries_sqlc/ # sqlc backend (optional)
├── backend.go # backend interface
├── backend_postgres.go # PostgreSQL implementation
├── backend_sqlite.go # SQLite implementation
├── queries.go # public API
└── sqlc/ # generated code (DO NOT EDIT)
├── postgres/
│ ├── auth.sql.go
│ ├── customer.sql.go
│ └── ...
└── sqlite/
├── auth.sql.go
├── customer.sql.go
└── ...Never edit files in internal/queries_sqlc/sqlc/ — they are regenerated by make sqlc-generate.
Build tags
The sqlc backend is gated behind the sqlc build tag:
go
// +build sqlc
package queries_sqlcThis means:
- Without
-tags sqlc: raw SQL backend is used (internal/queries) - With
-tags sqlc: sqlc backend is used (internal/queries_sqlc)
The application's main.go imports the appropriate backend based on build tags.
Workflow summary
First time setup:
bashmake install-sqlc make sqlc-generateDaily development (raw SQL backend):
bashgo build ./cmd # default: raw SQL go test ./... -count=1 -raceUsing sqlc backend:
bash# After editing db/queries/**/*.sql: make sqlc-generate # Build and test: make build-sqlc make test-queries-sqlc-sqlite make test-queries-sqlc-postgres # Or test all 4 modes: make test-queries-allBefore merging:
bashmake test-queries-all # all 4 modes must pass make build-both # both backends must compile
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
sqlc: command not found | sqlc not installed | make install-sqlc |
ERROR: relation "table_name" does not exist | Schema out of sync with queries | Ensure db/schema/ migrations are up-to-date |
query parameter $1 is missing | PostgreSQL query uses ? instead of $1 | Use $1, $2, ... in db/queries/postgres/*.sql |
query parameter ? is missing | SQLite query uses $1 instead of ? | Use ? in db/queries/sqlite/*.sql |
internal/queries_sqlc/sqlc/: directory not found | Generated code missing | make sqlc-generate |
Tests fail with undefined: GetCustomerByEmail | Generated code not compiled with -tags sqlc | go test -tags sqlc ./internal/queries_sqlc/... |
cannot use row (type pggen.GetCustomerByEmailRow) as type CustomerRow | Missing wrapper method | Implement wrapper in backend_postgres.go and backend_sqlite.go |
PostgreSQL test fails with invalid userinfo | Space or special character in password | URL-encode the password (space → %20, @ → %40) |
Further reading
- sqlc documentation
- sqlc query annotations
- Using PostgreSQL — PostgreSQL setup and testing
internal/queries_sqlc/AGENTS.md— implementation details for contributors