---
title: "20 — Database Access and Logging — pgx, GORM, Zap, and Zerolog"
uid: data-and-logging
tags: ["observability", "database", "golang", "logging", "gorm", "zap", "database-sql", "pgx", "roadmap:golang", "zerolog"]
excerpt: "Go offers three deliberate database tiers — raw database/sql, a driver like pgx, an ORM like GORM — and two logging speeds: slog for most, Zap/Zerolog when allocations matter."
date: 2026-08-13T03:28:08+0000
source: https://www.aveshina.my.id/en/blog/data-and-logging
---

Databases and logging pair in my notes because both are about observing what a running service does. The model that clicked: **Go offers a deliberate spectrum of database access, from raw SQL with full control (database/sql + pgx) to a struct-mapping ORM (GORM), and an equally deliberate spectrum of logging, from the built-in slog to zero-allocation libraries (Zap, Zerolog) for high-throughput services.** [1][5] There is no single right answer — the choice is a tradeoff between control and ergonomics, and the same service may pick differently at different scales.

## The three tiers of database access

Go does not force one database style. Three tiers cover the design space [1]:

- **database/sql** — the standard library's database interface. You write raw SQL, manage connections, and scan rows into structs manually. Maximum control, maximum verbosity, zero magic. The right choice when SQL expertise is high and query shape is performance-critical.
- **A native driver like pgx** — a PostgreSQL-specific driver that offers both database/sql compatibility and its own richer API. The middle ground: still close to SQL, but with Postgres-specific features (LISTEN/NOTIFY, COPY, better performance) when you want them [2].
- **An ORM like GORM** — maps Go structs to database tables, generates queries, runs migrations, and manages associations. Maximum ergonomics, at the cost of generated SQL you do not fully control and a learning curve for the ORM's own query DSL [3].

The recurring Go advice — "use the simplest tool that works" — pushes many services toward database/sql or pgx rather than an ORM. The reason is not purism; it is that ORMs in any language generate SQL that is hard to optimize, and Go's culture values predictability. But for CRUD-heavy services where the team wants to move fast and the query shapes are simple, GORM's productivity wins.

## pgx — the Postgres-native driver

pgx is the recommended PostgreSQL driver for Go [2]. It is pure Go (no C bindings), faster than the older lib/pq, and offers a native API beyond what database/sql exposes:

- LISTEN/NOTIFY for pub/sub-style notifications.
- COPY for bulk data loading.
- Better array, JSON, and UUID support.
- Its own connection pooling, independent of database/sql.

A service can use pgx in two modes: through database/sql (so any database/sql-compatible code works), or directly via pgx.Conn/pgx.Pool for the Postgres-specific features. For a new Postgres-backed Go service, pgx is the default recommendation — it has effectively replaced lib/pq.

## GORM — the ORM

GORM is the most popular Go ORM [3]. You define a struct, and GORM maps it to a table:

```
type User struct {
    gorm.Model        // embedded ID, CreatedAt, UpdatedAt, DeletedAt
    Name  string
    Email string `gorm:"unique"`
}

db.AutoMigrate(&User{})               // creates/updates the table
db.Create(&User{Name: "Ave"})         // INSERT
db.First(&user, 1)                    // SELECT ... WHERE id = 1
db.Where("name = ?", "Ave").Find(&users)  // parameterized query
```

GORM handles migrations, associations (has-many, many-to-many), hooks (before/after create), and transactions. It supports MySQL, Postgres, SQLite, and SQL Server behind one API. The tradeoff is the universal ORM tradeoff: simple queries are trivially ergonomic, but complex queries either require dropping to raw SQL (which GORM supports via db.Raw) or contorting through its DSL. The other cost is that GORM's behavior on edge cases (soft deletes, eager loading) is implicit, and teams that do not learn its conventions hit surprises.

The deciding question: is the database schema a thing the team designs and controls (favor pgx/raw SQL), or is it a persistence detail the structs drive (favor GORM)? Both are valid; mixing them in one service is where the pain lives.

## Logging — the three-speed spectrum

Logging has the same tiered shape. Three options cover most services [4]:

- **log/slog** (standard library, Go 1.21+) — structured, leveled, JSON-friendly. The default for new services. Enough for the large majority of cases.
- **Zap** (Uber) — high-performance structured logger with careful memory management. Allocates far less than slog or logrus, so it scales to high-throughput services without GC pressure [6].
- **Zerolog** — zero-allocation JSON logger with a fluent API. Even leaner than Zap on allocations, at the cost of a stricter API style [7].

The choice is driven by throughput. A typical CRUD API generates a few hundred log lines per second, and slog is perfectly adequate — there is no measurable overhead. A high-throughput proxy, streaming pipeline, or ad server generating millions of log lines per second feels the allocation cost, and that is where Zap or Zerolog earn their keep. The old guard (logrus) is feature-rich but allocates heavily by modern standards and is no longer the recommendation for new code.

## Structured logging — the discipline

Regardless of which logger, the discipline is the same: emit **structured records** with key-value pairs, not free-form strings [4]. This makes logs machine-parseable and queryable in a log aggregator (Elasticsearch, Loki, Datadog):

```
slog.Info("order completed",
    "order_id", order.ID,
    "user_id", order.UserID,
    "total_cents", order.Total,
    "duration_ms", elapsed.Milliseconds(),
)
```

Each call produces a record with a timestamp, level, message, and the key-value attributes. A query like "show me all orders for user 42 with total over $100" becomes a filter on fields, not a fragile string search. Levels (Debug/Info/Warn/Error) let you control verbosity per environment — Debug in development, Warn and above in production. And integrating with context so a trace ID flows into every log line ties logs to distributed traces, which is how you follow a request across services.

## How I use this

For a new Postgres service, my default is pgx directly, with queries written as SQL strings in a repository layer — the control is worth the verbosity, and the schema is something I design, not something structs generate. I reach for GORM only when the service is CRUD-heavy and the team wants the migration and association ergonomics. On logging, slog is my starting point for every service; I migrate to Zap or Zerolog only when profiling shows logging allocations showing up in the flame graph, which for most services they never do. Structured records with a trace ID propagated through context are non-negotiable from day one, because retrofitting structure onto string logs is far more painful than starting structured. The two spectrums — database control, logging speed — give a team the levers to tune as the service grows without ripping out the data layer.

## References

[1] Encore, "Go ORMs Compared," dev.to, 2024. [Online]. Available: [https://dev.to/encore/go-orms-compared-2c8g](https://dev.to/encore/go-orms-compared-2c8g)

[2] Better Stack, "Getting Started with PostgreSQL in Go using PGX," 2024. [Online]. Available: [https://betterstack.com/community/guides/scaling-go/postgresql-pgx-golang/](https://betterstack.com/community/guides/scaling-go/postgresql-pgx-golang/)

[3] GORM, "GORM — The fantastic ORM library for Golang," gorm.io, 2024. [Online]. Available: [https://gorm.io/](https://gorm.io/)

[4] The Go Authors, "Structured Logging with slog," The Go Blog, 2023. [Online]. Available: [https://go.dev/blog/slog](https://go.dev/blog/slog)

[5] R. Gatto, "Master Data Management in Go: ORM & Libraries Guide," Medium, 2024. [Online]. Available: [https://medium.com/@romulo.gatto/master-data-management-in-go-orm-libraries-guide-cd30cd65cba0](https://medium.com/@romulo.gatto/master-data-management-in-go-orm-libraries-guide-cd30cd65cba0)

[6] Better Stack, "A Comprehensive Guide to Zap Logging in Go," 2024. [Online]. Available: [https://betterstack.com/community/guides/logging/go/zap/](https://betterstack.com/community/guides/logging/go/zap/)

[7] Better Stack, "A Complete Guide to Logging in Go with Zerolog," 2024. [Online]. Available: [https://betterstack.com/community/guides/logging/zerolog/](https://betterstack.com/community/guides/logging/zerolog/)

```quiz
Q: Why might a team choose database/sql + pgx over an ORM like GORM?
- pgx is the only way to connect to Postgres from Go
- to keep full control over SQL and avoid generated queries that are hard to optimize, when SQL expertise is high and predictability is valued
correct: 1
explain: Raw SQL with pgx gives explicit, tunable queries. ORMs generate SQL that can be hard to optimize. The tradeoff is control vs ergonomics; Go's culture leans toward control.

Q: What does pgx offer beyond the older lib/pq driver?
- nothing — they are interchangeable
- better performance, Postgres-specific features (LISTEN/NOTIFY, COPY, arrays), and its own native API alongside database/sql compatibility
correct: 1
explain: pgx is pure Go, faster than lib/pq, and exposes Postgres features that database/sql cannot. It has effectively replaced lib/pq as the recommended Postgres driver.

Q: When should you reach for Zap or Zerolog instead of slog?
- always — slog is deprecated
- only for high-throughput services where logging allocations show up in profiling; for most services slog is fast enough
correct: 1
explain: slog is the modern default and adequate for the large majority of services. Zap/Zerolog earn their keep when allocation counts matter (millions of log lines per second), not for ordinary APIs.

Q: Structured logging (key-value records instead of free-form strings) matters because…
- it is required by the Go compiler
- it makes logs machine-parseable and queryable in a log aggregator, so filtering by field beats fragile string search
correct: 1
explain: Structured records let you query "all orders for user 42 over $100" as field filters. Free-form strings force substring searches that break when the message changes.

Q: The Go community generally recommends…
- always using a full ORM for any database access
- using the simplest tool that works — often database/sql or pgx — reserving ORMs for CRUD-heavy services where their ergonomics pay off
correct: 1
explain: Go's culture values predictability and SQL control. ORMs are a valid choice for ergonomics, but the default lean is toward explicit SQL until the ORM's productivity clearly wins.
```
