Skip to content

Why sqlc — not an ORM

The Go ecosystem has ORMs — GORM, ent, Pop. Laju Go uses none of them. It uses sqlc, a compiler that reads SQL and generates type-safe Go. The reasoning is simple: SQL is the right language for database work, and Go is the right language for application logic. An ORM forces you to write SQL in a worse language and then translates it back to SQL at runtime.

The input is a .sql file with named queries. Here’s the entire password-reset query set in Laju Go:

-- queries/password_reset.sql
-- name: CreatePasswordReset :exec
INSERT INTO password_resets (token, user_id, email, expires_at, created_at)
VALUES (?, ?, ?, ?, ?);
-- name: GetPasswordReset :one
SELECT * FROM password_resets WHERE token = ? AND used = 0 AND expires_at > ?;
-- name: MarkPasswordResetUsed :exec
UPDATE password_resets SET used = 1 WHERE token = ?;
-- name: DeleteExpiredPasswordResets :exec
DELETE FROM password_resets WHERE expires_at < CURRENT_TIMESTAMP;

npm run db:generate runs sqlc, which produces Go code in app/queries/ — typed query constants, parameter structs, and methods on a Queries struct. The generated code for CreateUser looks like this:

user.sql
// Code generated by sqlc. DO NOT EDIT.
const createUser = `-- name: CreateUser :one
INSERT INTO users (email, name, password, role, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
RETURNING id
`
type CreateUserParams struct {
Email string
Name string
Password sql.NullString
Role string
CreatedAt time.Time
UpdatedAt time.Time
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (int64, error) {
row := q.db.QueryRowContext(ctx, createUser,
arg.Email, arg.Name, arg.Password, arg.Role, arg.CreatedAt, arg.UpdatedAt,
)
var id int64
err := row.Scan(&id)
return id, err
}

The service layer calls it like a normal function:

id, err := s.querier.CreateUser(ctx, queries.CreateUserParams{
Email: email,
Name: name,
Password: sql.NullString{String: hash, Valid: true},
Role: "user",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})

No string building. No .Where("email = ?", email).First(&user). No struct tags that secretly control SQL generation. The SQL is the source of truth and the Go is derived from it.

If you rename a column in the schema and forget to update a query, sqlc fails at generation time — before the app compiles. If you pass the wrong type to a parameter, the Go compiler catches it. There is no interface{} reflection at runtime, no “query returned 3 columns but struct has 4” panic in production.

The app/queries/ directory is generated and committed. The rule in AGENTS.md is explicit: never edit app/queries/ manually. You edit the .sql file, run db:generate, and the Go updates. This keeps the SQL as the single source of truth and prevents the generated code from drifting.

GORM and ent use reflection and struct tags to build queries at runtime. That means allocations, interface boxing, and a non-trivial CPU cost per query. sqlc generates plain QueryRowContext / QueryContext calls with the SQL as a string constant. The runtime cost is the database driver and nothing else. For a high-throughput SaaS, that’s measurable.

This is the part that matters in 2026. LLMs are excellent at SQL and mediocre at ORM DSLs. When the data layer is plain SQL, an AI agent can write a new query — -- name: GetUserByEmail :one\nSELECT * FROM users WHERE email = ? — and sqlc generates the Go. The agent doesn’t need to know GORM’s chainable API, ent’s schema definition syntax, or which struct tag means “primary key.” It writes SQL, which is the thing it’s best at, and the compiler does the rest.

The three-tier rule in Laju Go enforces this: the Query layer is the only layer that executes SQL. Services call s.querier.*. Handlers never touch the database. This means an AI agent adding a feature writes SQL + a service method + a handler, and each layer has a clear contract. The ORM version of this task requires the agent to understand the ORM’s mental model, its migration system, and its query builder — all of which are worse than SQL for the database part.

sqlc doesn’t do migrations (Laju Go uses Goose for that). It doesn’t do relationships — there’s no .Preload("orders"). If you want eager loading, you write a JOIN in SQL, which is what you should do anyway. And it’s SQL-first, so if you don’t want to write SQL, it’s not for you. But if you’re building a SaaS and you’re willing to write SQL, sqlc gives you type safety, compile-time checking, zero runtime overhead, and an AI-friendly data layer — all at once.