The JSON column problem
You're building a user profile service. The database stores user preferences in a settings column. The column type is JSONB. You write a query to fetch a user, and now you need that JSON blob in your Go code. You could treat it as a string and parse it manually, but that feels fragile. You could define a struct for every possible shape, but the schema changes weekly. PostgreSQL gives you a native JSON type, and Go gives you encoding/json. The bridge between them is where the magic happens, and also where the bugs hide.
The pgx driver is the standard way to talk to PostgreSQL from Go. It knows how to read JSON columns, but it needs you to tell it what to do with the data. You have choices. You can ask pgx to decode the JSON immediately into a Go struct. You can ask it to hand you the raw bytes and parse later. Or you can treat the JSON as an opaque blob and pass it through. Each approach has trade-offs in performance, flexibility, and safety.
How pgx bridges the gap
PostgreSQL stores JSON as a binary format on disk, but sends it over the wire as text. The pgx driver reads that text and hands it to Go. You have two main choices for receiving the data. You can ask pgx to decode it directly into a Go struct, mapping JSON keys to struct fields. Or you can ask pgx to hand you the raw bytes without decoding, using json.RawMessage.
json.RawMessage is just a type alias for []byte. It tells the JSON package, "Hold this data. Don't parse it yet. I'll tell you when to look inside." This distinction matters for performance and flexibility. If you scan into a struct, the driver calls json.Unmarshal immediately. If you scan into json.RawMessage, the driver skips unmarshaling and gives you the bytes. You pay the parsing cost only when you explicitly call json.Unmarshal on the raw message.
Think of the JSON column as a sealed envelope. json.RawMessage is like keeping the envelope sealed until you're ready to open it. A struct is like opening the envelope immediately and putting the contents into specific drawers. If you don't know what's inside, keep it sealed. If you know exactly what's inside, open it and sort it.
Scan directly into a struct
Here's the direct scan approach: define a struct with json tags, then scan the column straight into it. This is the fastest path when the schema is stable.
package main
import (
"context"
"fmt"
"log"
"github.com/jackc/pgx/v5/pgxpool"
)
// UserSettings holds a predictable subset of the JSON data.
type UserSettings struct {
// Theme maps to the "theme" key in JSON.
// If the key is missing, the field stays zero-valued.
Theme string `json:"theme"`
// DarkMode maps to "dark_mode".
// The json tag handles key name mismatches automatically.
DarkMode bool `json:"dark_mode"`
}
func main() {
ctx := context.Background()
// pgxpool manages a pool of connections.
// Always pass context to database calls to support cancellation.
pool, err := pgxpool.New(ctx, "postgres://localhost:5432/testdb")
if err != nil {
log.Fatalf("connection failed: %v", err)
}
defer pool.Close()
var settings UserSettings
// Scan decodes the JSON column directly into the struct.
// pgx calls json.Unmarshal under the hood using the struct tags.
err = pool.QueryRow(ctx, "SELECT settings FROM users WHERE id = $1", 1).Scan(&settings)
if err != nil {
log.Fatalf("query failed: %v", err)
}
fmt.Printf("Theme: %s, Dark: %v\n", settings.Theme, settings.DarkMode)
}
Scan into structs for speed and safety. The compiler catches field mismatches, and you get autocomplete in your editor.
What happens under the hood
When you call Scan(&settings), the driver checks the type of the destination. It sees a struct. It takes the raw JSON bytes from the database and passes them to json.Unmarshal. The unmarshaler reads the JSON keys, matches them against the json struct tags, and sets the fields. If the JSON contains a key that doesn't match any tag, the unmarshaler ignores it by default. If the JSON structure doesn't match the structβfor example, a string where a bool is expectedβthe unmarshaler returns an error, and Scan propagates that error back to you.
The driver uses the pgtype package to handle type mapping. For JSON columns, pgtype recognizes the target type and selects the appropriate decoder. If the target is a struct, it uses the JSON decoder. If the target is []byte or json.RawMessage, it uses the raw decoder. This flexibility is why pgx works so well with Go's type system.
Convention aside: the pgx driver follows Go conventions. Functions take context.Context as the first argument. Errors are returned as the last value. If you see a function signature without context, it's likely a helper, not the entry point. Always thread context through your database calls. It enables timeouts and cancellation, which are essential for production systems.
Defer parsing with json.RawMessage
Here's the deferral pattern: scan into json.RawMessage, inspect the structure, then parse only the parts you need. This saves CPU cycles when you have large JSON blobs or dynamic schemas.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"github.com/jackc/pgx/v5/pgxpool"
)
// Metadata wraps the raw JSON payload.
// Using json.RawMessage defers parsing until you actually need the data.
type Metadata struct {
// Raw holds the unprocessed JSON bytes from the database.
// This avoids parsing overhead for data you might not need.
Raw json.RawMessage
}
func main() {
ctx := context.Background()
pool, err := pgxpool.New(ctx, "postgres://localhost:5432/testdb")
if err != nil {
log.Fatalf("connection failed: %v", err)
}
defer pool.Close()
var meta Metadata
// Scan into RawMessage to get bytes without decoding.
// The driver skips json.Unmarshal and returns the raw bytes.
err = pool.QueryRow(ctx, "SELECT metadata FROM events WHERE id = $1", 42).Scan(&meta.Raw)
if err != nil {
log.Fatalf("query failed: %v", err)
}
// Check if a specific key exists before parsing the whole object.
// This pattern avoids unmarshaling large payloads unnecessarily.
var temp map[string]json.RawMessage
if err := json.Unmarshal(meta.Raw, &temp); err != nil {
log.Fatalf("unmarshal failed: %v", err)
}
if payload, ok := temp["payload"]; ok {
// Only parse the nested payload if the key is present.
// This keeps memory usage low for unused fields.
var payloadData map[string]interface{}
if err := json.Unmarshal(payload, &payloadData); err != nil {
log.Fatalf("nested unmarshal failed: %v", err)
}
fmt.Printf("Found payload: %v\n", payloadData)
} else {
fmt.Println("No payload key found.")
}
}
Don't parse JSON you don't need. json.RawMessage is your friend when the schema varies or the payload is large.
Pitfalls and edge cases
Scanning JSON columns has traps. The first is passing a value instead of a pointer. Scan requires a pointer so it can modify the destination. If you pass var s User, the driver rejects the call with non-pointer passed to Scan. Always pass &s.
The second trap is NULL columns. If the JSON column is NULL, Scan fails unless the target can hold a null value. Scanning into a struct field works if the field is a pointer or uses sql.NullString. If you scan into a non-pointer struct and the column is null, you get a mismatch error. The driver complains with cannot scan NULL into *string if the column is null and the target isn't nullable. Use pointers for optional JSON fields.
The third trap is printing json.RawMessage. Since it's []byte, printing it directly shows byte values, not JSON text. You need string(raw) to see the readable JSON. This catches people off guard when debugging.
The fourth trap is large numbers. JSON numbers are floats in the spec, but Go maps them to float64 by default. If your JSON contains IDs larger than 2^53, precision is lost. Use json.Decoder with UseNumber to preserve exact values, or store IDs as strings in JSON. This is a subtle bug that surfaces only with large datasets.
Convention aside: error handling in Go is explicit. The if err != nil check is verbose by design. It forces you to acknowledge the failure path. Don't use _ to discard errors from database calls; the database can fail at any moment. The community accepts the boilerplate because it makes the unhappy path visible. Trust the verbosity. It saves you in production.
Decision matrix
Use a Go struct with json tags when the JSON schema is stable and you need type safety. The compiler catches mismatches, and you get autocomplete in your editor.
Use json.RawMessage when the JSON structure varies or you only need to inspect a few keys. Deferring parsing saves CPU cycles and lets you handle dynamic data without defining a struct for every shape.
Use map[string]interface{} when you need to iterate over keys dynamically and don't care about types. This is slower and loses type safety, but it works for arbitrary JSON blobs.
Use a string scan only when you need to pass the JSON through to another system without touching it. Parsing a string manually is error-prone; let the driver handle the bytes.
PostgreSQL sends bytes. Go wants values. The driver bridges the gap, but you control the destination.