Why Go + PostgreSQL + sqlc Is the Secret to High‑Concurrency Backend Architecture
This article explains how combining Go, PostgreSQL, and sqlc creates a Zero‑ORM architecture that restores SQL to the center of high‑performance, highly controllable, and maintainable backend services, covering design principles, layer responsibilities, schema and query patterns, connection‑pool tuning, transaction handling, observability, and practical best‑practice checklists.
Why This Stack Is Becoming a Go Backend Paradigm
In Go server development there are two traditional data‑access paths: an ORM‑centric path that emphasizes model mapping and rapid CRUD, and an explicit‑SQL path that prioritises query control, predictable performance, and debuggability. ORMs work well for small projects or quick prototypes, but in high‑concurrency scenarios they expose limitations such as complex query leakage, unpredictable latency, the need for observability, and the desire for fine‑grained control over execution plans, indexes, and isolation levels.
The combination of sqlc, pgx, and PostgreSQL directly addresses these pain points.
Engineers write raw SQL. sqlc parses the SQL at compile time and generates type‑safe Go code. pgx provides high‑performance PostgreSQL connections, transaction handling, and protocol‑level features.
The application layer engineers the query, transaction, index, connection‑pool, and observability concerns.
This architecture does not aim to be “trendy”; it restores database access from a black‑box to a first‑class, design‑time, and verification‑time system capability.
Zero‑ORM Essence: Moving Runtime Problems to Design‑ and Compile‑Time
ORM Problems Beyond Performance
Many articles focus on ORM reflection overhead, yet real‑world issues fall into three categories:
Abstraction leakage – databases are declarative systems with indexes, locks, isolation levels, execution plans, aggregation, and concurrency control that ORMs cannot fully abstract.
Runtime uncertainty – generated code may hide mismatched field names, unexpected SQL, N+1 queries, full‑table scans, or NULL/zero‑value mapping problems that only surface at runtime.
Unpredictable performance – reflection, automatic eager loading, dynamic SQL generation, and inefficient batch operations make latency spikes hard to predict, which is unacceptable for API‑centric services where predictability outweighs syntactic sugar.
How sqlc Works: Type‑Safe + Native SQL Control
sqlcis not an ORM nor a query builder; it is a SQL‑to‑Go compiler.
schema.sql / migration files
+
queries.sql
|
v
sqlc analyze
|
v
// Generates Go structs, params, methods, interfacesIt performs three main steps:
Parse the database schema.
Parse the developer‑written SQL.
Generate static, type‑safe Go code based on result sets and parameters.
Example:
-- name: GetUserByID :one
SELECT id, email, name, status, created_at FROM users WHERE id = $1; func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (User, error)This guarantees that parameter types, return structs, column order, and scanning are all compile‑time verified, eliminating the need for manual Scan(...) calls.
Why pgx Over database/sql
While database/sql provides a generic interface, pgx offers PostgreSQL‑specific capabilities that matter in production:
Full PostgreSQL type system support.
Copy protocol, batch execution, and advanced transaction control.
Mature connection‑pool implementation ( pgxpool) with fine‑grained tuning.
Rich error codes that map directly to PostgreSQL error conditions.
Production‑Grade Architecture: Not Just Knowing SQL
A Zero‑ORM service should be layered as follows:
HTTP / gRPC Handler
|
v
Application Service
|
v
Repository / Store (sqlc‑generated queries)
|
v
pgxpool / pgx.Tx
|
v
PostgreSQLHandler Layer Responsibilities
Parameter validation.
Authentication and tenant identification.
Context timeout and cancellation propagation.
Response encoding.
Business logic and transaction boundaries must stay out of the handler.
Service Layer Responsibilities
Orchestrate business workflows.
Define transaction boundaries.
Enforce business rules.
Compose multiple query methods.
Transactions should be started in the service layer to keep consistency logic close to the domain.
Repository / Store Layer Responsibilities
Hold the sqlc generated Queries object.
Expose domain‑friendly read/write methods.
Hide raw SQL details from upper layers.
Provide uniform error translation.
This separation prevents SQL‑specific types from leaking into business code.
Schema Design for High Concurrency
Key schema decisions:
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
age INTEGER NOT NULL CHECK (age >= 0),
status VARCHAR(20) NOT NULL DEFAULT 'active',
profile JSONB NOT NULL DEFAULT '{}'::jsonb,
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL
);
-- Partial unique index for soft‑delete safety
CREATE UNIQUE INDEX uk_users_email_active ON users(email) WHERE deleted_at IS NULL;
-- Composite index for common list queries
CREATE INDEX idx_users_status_created_at ON users(status, created_at DESC) WHERE deleted_at IS NULL;
-- GIN index for JSONB search
CREATE INDEX idx_users_profile_gin ON users USING GIN (profile);Partial indexes avoid email collisions on soft‑deleted rows; composite indexes support typical status‑filtered, time‑ordered pagination; JSONB stores flexible attributes without polluting core filters.
SQL Design: Explicit Business Expression
All queries are written explicitly, avoiding hidden ORM assumptions. Example of keyset pagination (preferred over OFFSET for deep pages):
SELECT ... FROM users
WHERE status = $1 AND deleted_at IS NULL AND created_at < $2
ORDER BY created_at DESC
LIMIT $3;Keyset pagination guarantees index‑friendly ordering and stable performance even with large offsets.
sqlc Configuration: Stable Interface Generation
version: "2"
sql:
- engine: "postgresql"
schema:
- "internal/db/migrations"
queries:
- "internal/db/queries"
gen:
go:
package: "sqlc"
out: "internal/db/sqlc"
sql_package: "pgx/v5"
emit_json_tags: true
emit_interface: true
emit_empty_slices: true
emit_result_struct_pointers: false
emit_methods_with_db_argument: false
overrides:
- db_type: "uuid"
go_type: "github.com/google/uuid.UUID"
- db_type: "pg_catalog.timestamptz"
go_type: "time.Time"
- db_type: "jsonb"
go_type: "[]byte"Key recommendations: enable emit_interface for mockability, emit empty slices to avoid nil JSON, map jsonb to []byte for raw storage.
Connection‑Pool Initialization
// internal/db/postgres.go
package db
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type Config struct {
DSN string
MaxConns int32
MinConns int32
MaxConnLifetime time.Duration
MaxConnIdleTime time.Duration
HealthCheckPeriod time.Duration
}
func NewPool(ctx context.Context, cfg Config) (*pgxpool.Pool, error) {
poolCfg, err := pgxpool.ParseConfig(cfg.DSN)
if err != nil {
return nil, fmt.Errorf("parse pg config: %w", err)
}
poolCfg.MaxConns = cfg.MaxConns
poolCfg.MinConns = cfg.MinConns
poolCfg.MaxConnLifetime = cfg.MaxConnLifetime
poolCfg.MaxConnIdleTime = cfg.MaxConnIdleTime
poolCfg.HealthCheckPeriod = cfg.HealthCheckPeriod
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
if err != nil {
return nil, fmt.Errorf("new pg pool: %w", err)
}
pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
if err := pool.Ping(pingCtx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping postgres: %w", err)
}
return pool, nil
}Pool size must be tuned to the database instance capacity; blindly increasing it when latency spikes is a common anti‑pattern.
Transaction Helper
// internal/db/sqlc_tx.go
package db
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
dbsqlc "your-project/internal/db/sqlc"
)
func ExecTx(ctx context.Context, pool *pgxpool.Pool, fn func(q *dbsqlc.Queries) error) error {
tx, err := pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
q := dbsqlc.New(tx)
if err := fn(q); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
return nil
}All database work inside the callback runs in a single transaction; external calls (e.g., HTTP, Kafka) should be performed outside the transaction or via an outbox pattern.
Repository Implementation Example
// internal/repository/user_repository.go
package repository
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
dbsqlc "your-project/internal/db/sqlc"
)
var (
ErrUserNotFound = errors.New("user not found")
ErrEmailAlreadyExist = errors.New("email already exists")
ErrVersionConflict = errors.New("version conflict")
)
type UserRepository struct {
pool *pgxpool.Pool
q *dbsqlc.Queries
}
func NewUserRepository(pool *pgxpool.Pool) *UserRepository {
return &UserRepository{pool: pool, q: dbsqlc.New(pool)}
}
func (r *UserRepository) Create(ctx context.Context, arg dbsqlc.CreateUserParams) (dbsqlc.User, error) {
user, err := r.q.CreateUser(ctx, arg)
if err != nil {
if isUniqueViolation(err) {
return dbsqlc.User{}, ErrEmailAlreadyExist
}
return dbsqlc.User{}, fmt.Errorf("create user: %w", err)
}
return user, nil
}
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (dbsqlc.User, error) {
user, err := r.q.GetUserByEmail(ctx, email)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbsqlc.User{}, ErrUserNotFound
}
return dbsqlc.User{}, fmt.Errorf("get user by email: %w", err)
}
return user, nil
}
func (r *UserRepository) UpdateProfile(ctx context.Context, arg dbsqlc.UpdateUserProfileParams) (dbsqlc.User, error) {
user, err := r.q.UpdateUserProfile(ctx, arg)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbsqlc.User{}, ErrVersionConflict
}
return dbsqlc.User{}, fmt.Errorf("update user profile: %w", err)
}
return user, nil
}
func (r *UserRepository) Delete(ctx context.Context, id uuid.UUID) error {
rows, err := r.q.SoftDeleteUser(ctx, id)
if err != nil {
return fmt.Errorf("soft delete user: %w", err)
}
if rows == 0 {
return ErrUserNotFound
}
return nil
}
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}Service Layer Example (User Registration & Update)
// internal/service/user_service.go
package service
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
dbsqlc "your-project/internal/db/sqlc"
"your-project/internal/repository"
)
type UserService struct {
pool *pgxpool.Pool
repo *repository.UserRepository
}
type RegisterUserRequest struct {
Email string
Name string
Age int32
Profile map[string]any
}
type UpdateUserRequest struct {
ID string
Name string
Age int32
Version int64
Profile map[string]any
}
func NewUserService(pool *pgxpool.Pool, repo *repository.UserRepository) *UserService {
return &UserService{pool: pool, repo: repo}
}
func (s *UserService) Register(ctx context.Context, req RegisterUserRequest) (dbsqlc.User, error) {
profileBytes, err := json.Marshal(req.Profile)
if err != nil {
return dbsqlc.User{}, fmt.Errorf("marshal profile: %w", err)
}
return s.repo.Create(ctx, dbsqlc.CreateUserParams{
Email: req.Email,
Name: req.Name,
Age: req.Age,
Status: "active",
Profile: profileBytes,
})
}
func (s *UserService) UpdateProfile(ctx context.Context, req UpdateUserRequest) (dbsqlc.User, error) {
profileBytes, err := json.Marshal(req.Profile)
if err != nil {
return dbsqlc.User{}, fmt.Errorf("marshal profile: %w", err)
}
uid, err := uuid.Parse(req.ID)
if err != nil {
return dbsqlc.User{}, fmt.Errorf("parse user id: %w", err)
}
return s.repo.UpdateProfile(ctx, dbsqlc.UpdateUserProfileParams{
ID: uid,
Name: req.Name,
Age: req.Age,
Profile: profileBytes,
Version: req.Version,
})
}The service serialises JSONB fields, parses UUIDs, and relies on optimistic‑locking via the version column.
HTTP Handler Example
// internal/transport/http/user_handler.go
package http
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
"your-project/internal/repository"
"your-project/internal/service"
)
type UserHandler struct {
svc *service.UserService
}
func NewUserHandler(svc *service.UserService) *UserHandler { return &UserHandler{svc: svc} }
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Name string `json:"name"`
Age int32 `json:"age"`
Profile map[string]any `json:"profile"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
user, err := h.svc.Register(ctx, service.RegisterUserRequest{Email: req.Email, Name: req.Name, Age: req.Age, Profile: req.Profile})
if err != nil {
switch {
case errors.Is(err, repository.ErrEmailAlreadyExist):
writeJSON(w, http.StatusConflict, map[string]string{"error": "email already exists"})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
return
}
writeJSON(w, http.StatusCreated, user)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}Every request sets a timeout to avoid unbounded goroutine, connection, and memory consumption.
High‑Concurrency Governance: Beyond ORM Choice
Performance gains stem from controlling the entire request pipeline:
Reduce the number of SQL statements per request.
Ensure every query uses the appropriate index.
Keep transaction duration short; avoid network calls or heavy CPU work inside transactions.
Size the connection pool according to DB capacity, not just request spikes.
Mitigate hotspot contention with sharding, async aggregation, or row‑level locks.
Typical Optimisations
Batch writes via INSERT … SELECT FROM UNNEST, pgx.Batch, or COPY instead of loops.
Keyset pagination instead of deep OFFSET.
Optimistic locking for high‑contention updates.
Outbox pattern for reliable event publishing.
Common Pitfalls
Looping over individual lookups.
Deep OFFSET pagination.
Embedding external service calls inside a DB transaction.
Missing request timeouts.
Focusing only on average latency, ignoring P95/P99.
Increasing pool size as a first‑order fix for slow queries.
Observability & Operations
Key metrics to monitor:
Application side: request QPS, error rate, P50/P95/P99 latency, active pool connections, pool wait time, timeout count.
Database side: slow‑query count, average transaction duration, lock wait time, deadlock occurrences, buffer‑cache hit ratio, commits/rollbacks per second.
Enable PostgreSQL extensions such as pg_stat_statements, slow‑query logging, and automatic vacuum monitoring.
Decision Matrix: sqlc vs ORM
sqlc fits when:
Core transaction‑heavy services (user, order, inventory).
Complex queries, index‑sensitive workloads.
Strict transaction and query control is required.
The team is comfortable maintaining SQL as a first‑class asset.
ORM fits when:
Back‑office admin tools or quick prototypes.
Simple CRUD without heavy performance constraints.
The team lacks deep SQL expertise and cannot invest in query review.
Many organisations adopt a hybrid approach: core services use sqlc + pgx, while peripheral admin UIs keep an ORM for rapid development.
Best‑Practice Checklist
Split SQL files by domain, not by complexity.
Pass context.Context through every DB call.
Set request‑level timeouts.
Define transaction boundaries in the Service layer.
Design dedicated indexes for high‑frequency list queries.
Replace deep pagination with keyset pagination.
Use optimistic locking or row‑level strategies for hotspot updates.
Map low‑level DB errors to domain errors instead of leaking them.
Instrument slow‑query, connection‑pool, and lock‑wait metrics.
Review SQL in PRs, not just Go code.
Common Gotchas & Fixes
NULL Handling
Choose a consistent representation: pointer types, pgtype, or COALESCE in SQL.
JSONB Overuse
Reserve JSONB for flexible attributes; keep frequently filtered columns structured.
Misusing Generated Code as Service
Generated Queries should stay in the repository layer; business orchestration belongs in services.
Migration Management
sqlc does not handle migrations; pair it with tools like golang-migrate, goose, or atlas and adopt forward‑compatible change policies, gradual roll‑outs, and rollback plans.
Ignoring Write Contention
Focus on both read and write paths: monitor lock waits, deadlocks, and transaction durations.
Conclusion
The Go + PostgreSQL + sqlc stack is not merely a performance tweak; it re‑elevates SQL to a first‑class, design‑time artifact, provides compile‑time type safety, and enables fine‑grained observability and tuning. For simple CRUD, an ORM remains reasonable, but once data volume, concurrency, query complexity, or rapid fault localisation become priorities, Zero‑ORM with sqlc delivers lower total cost of ownership and a more mature engineering posture.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Ray's Galactic Tech
Practice together, never alone. We cover programming languages, development tools, learning methods, and pitfall notes. We simplify complex topics, guiding you from beginner to advanced. Weekly practical content—let's grow together!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
