Working with Data
JSON, HTTP clients, databases, and file I/O — the bread and butter of every Go backend service.
Real-World Analogy
A plumber’s toolkit — JSON is the pipe, HTTP clients are the fittings, SQL is the wrench. Each tool does one job well; this chapter teaches you to use them together without leaks.
JSON: The Universal Data Format
Every Go backend deals with JSON. Go’s encoding/json package uses struct tags to control serialization.
type User struct {
ID int `json:"id"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Password string `json:"-"` // Never serialize
Bio string `json:"bio,omitempty"` // Skip if empty
CreatedAt time.Time `json:"created_at"`
}
// Struct → JSON (Marshal)
user := User{
ID: 1,
Email: "alice@example.com",
FirstName: "Alice",
LastName: "Smith",
Password: "secret123",
CreatedAt: time.Now(),
}
data, err := json.Marshal(user)
// {"id":1,"email":"alice@example.com","first_name":"Alice","last_name":"Smith","created_at":"2024-01-15T10:30:00Z"}
// Note: Password excluded, Bio excluded (empty + omitempty)
// JSON → Struct (Unmarshal)
jsonStr := `{"id": 1, "email": "alice@example.com", "first_name": "Alice"}`
var parsed User
err := json.Unmarshal([]byte(jsonStr), &parsed) Streaming JSON (Large Data)
For large payloads, use json.Encoder/json.Decoder instead of Marshal/Unmarshal:
// Encode directly to a writer (HTTP response, file)
func writeJSON(w http.ResponseWriter, data any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
// Decode directly from a reader (HTTP request, file)
func readJSON(r io.Reader, dst any) error {
decoder := json.NewDecoder(r)
decoder.DisallowUnknownFields() // Reject unexpected fields
return decoder.Decode(dst)
} Use json.Decoder for HTTP request bodies, not json.Unmarshal. The decoder reads from the stream directly without buffering the entire body in memory. It also supports DisallowUnknownFields() which catches typos in field names.
Building HTTP Servers
Go’s net/http package is production-ready out of the box. No framework needed.
package main
import (
"encoding/json"
"log"
"net/http"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
var users = []User{
{ID: 1, Name: "Alice", Email: "alice@example.com"},
{ID: 2, Name: "Bob", Email: "bob@example.com"},
}
func main() {
mux := http.NewServeMux()
// Go 1.22+ pattern matching
mux.HandleFunc("GET /users", handleListUsers)
mux.HandleFunc("GET /users/{id}", handleGetUser)
mux.HandleFunc("POST /users", handleCreateUser)
log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
func handleListUsers(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
}
func handleGetUser(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id") // Go 1.22+ path parameter
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "invalid user ID", http.StatusBadRequest)
return
}
for _, u := range users {
if u.ID == id {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(u)
return
}
}
http.Error(w, "user not found", http.StatusNotFound)
}
func handleCreateUser(w http.ResponseWriter, r *http.Request) {
var input struct {
Name string `json:"name"`
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
user := User{
ID: len(users) + 1,
Name: input.Name,
Email: input.Email,
}
users = append(users, user)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
} Real-World Analogy
Go’s net/http is like a Swiss Army knife that happens to also be a chef’s knife. Most languages need a framework (Express, Flask, Spring) to build web servers. Go’s standard library is so good that companies like Cloudflare run production services on it directly. Frameworks like Gin or Echo add convenience, but they’re not required.
HTTP Middleware
Middleware wraps handlers to add cross-cutting concerns:
// Logging middleware
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}
// Auth middleware
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
userID, err := validateToken(token)
if err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
// Add user ID to request context
ctx := context.WithValue(r.Context(), "userID", userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Chain middleware
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /users", handleListUsers)
// Apply middleware (innermost runs first)
handler := loggingMiddleware(authMiddleware(mux))
http.ListenAndServe(":8080", handler)
} Making HTTP Requests
// Simple GET
resp, err := http.Get("https://api.example.com/users")
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
var users []User
json.NewDecoder(resp.Body).Decode(&users)
// POST with JSON body
func createUser(apiURL string, user User) (*User, error) {
body, err := json.Marshal(user)
if err != nil {
return nil, fmt.Errorf("marshaling user: %w", err)
}
resp, err := http.Post(apiURL+"/users", "application/json", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("POST request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, body)
}
var created User
json.NewDecoder(resp.Body).Decode(&created)
return &created, nil
}
// Production-grade HTTP client with timeout
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req) Never use http.DefaultClient in production. It has no timeout — a slow server will hang your goroutine forever. Always create a client with an explicit timeout.
Database Access with database/sql
Go’s database/sql is a thin, powerful abstraction over SQL databases:
import (
"database/sql"
_ "github.com/lib/pq" // PostgreSQL driver (blank import registers it)
)
func main() {
db, err := sql.Open("postgres", "postgres://user:pass@localhost/myapp?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Configure connection pool
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
// Verify connection
if err := db.Ping(); err != nil {
log.Fatal(err)
}
} CRUD Operations
// CREATE
func createUser(db *sql.DB, user *User) error {
return db.QueryRow(
`INSERT INTO users (email, name, created_at) VALUES ($1, $2, $3) RETURNING id`,
user.Email, user.Name, time.Now(),
).Scan(&user.ID)
}
// READ (single row)
func getUserByID(db *sql.DB, id int) (*User, error) {
var user User
err := db.QueryRow(
`SELECT id, email, name, created_at FROM users WHERE id = $1`, id,
).Scan(&user.ID, &user.Email, &user.Name, &user.CreatedAt)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("querying user %d: %w", id, err)
}
return &user, nil
}
// READ (multiple rows)
func listUsers(db *sql.DB, limit, offset int) ([]*User, error) {
rows, err := db.Query(
`SELECT id, email, name, created_at FROM users ORDER BY id LIMIT $1 OFFSET $2`,
limit, offset,
)
if err != nil {
return nil, fmt.Errorf("listing users: %w", err)
}
defer rows.Close()
var users []*User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.CreatedAt); err != nil {
return nil, fmt.Errorf("scanning user: %w", err)
}
users = append(users, &u)
}
return users, rows.Err() // Check for iteration errors
}
// UPDATE
func updateUser(db *sql.DB, id int, name string) error {
result, err := db.Exec(`UPDATE users SET name = $1 WHERE id = $2`, name, id)
if err != nil {
return fmt.Errorf("updating user %d: %w", id, err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return ErrNotFound
}
return nil
}
// DELETE
func deleteUser(db *sql.DB, id int) error {
result, err := db.Exec(`DELETE FROM users WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("deleting user %d: %w", id, err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return ErrNotFound
}
return nil
} Always call rows.Close() and check rows.Err() when iterating query results. Forgetting Close() leaks database connections. Forgetting Err() silently drops errors that happen during iteration.
Transactions
func transferFunds(db *sql.DB, fromID, toID int, amount float64) error {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("starting transaction: %w", err)
}
// Rollback if anything fails (no-op if already committed)
defer tx.Rollback()
// Debit
var balance float64
err = tx.QueryRow(`SELECT balance FROM accounts WHERE id = $1 FOR UPDATE`, fromID).Scan(&balance)
if err != nil {
return fmt.Errorf("checking balance: %w", err)
}
if balance < amount {
return fmt.Errorf("insufficient funds: have %.2f, need %.2f", balance, amount)
}
_, err = tx.Exec(`UPDATE accounts SET balance = balance - $1 WHERE id = $2`, amount, fromID)
if err != nil {
return fmt.Errorf("debiting: %w", err)
}
// Credit
_, err = tx.Exec(`UPDATE accounts SET balance = balance + $1 WHERE id = $2`, amount, toID)
if err != nil {
return fmt.Errorf("crediting: %w", err)
}
return tx.Commit()
} File I/O
// Read entire file
data, err := os.ReadFile("config.json")
if err != nil {
log.Fatal(err)
}
// Write entire file
err := os.WriteFile("output.txt", []byte("hello world"), 0644)
// Read file line by line (memory efficient for large files)
file, err := os.Open("large-file.csv")
if err != nil {
log.Fatal(err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
// process line
}
if err := scanner.Err(); err != nil {
log.Fatal(err)
} Key Takeaways
- Use struct tags to control JSON field names —
json:"field_name,omitempty" - Use
json.Decoderfor HTTP bodies,json.Marshalfor in-memory data - Go 1.22+ has built-in routing —
mux.HandleFunc("GET /users/{id}", handler) - Always set HTTP client timeouts —
&http.Client{Timeout: 10 * time.Second} database/sqlmanages connection pools — configureMaxOpenConnsandMaxIdleConns- Check
rows.Err()after iteration — silent errors are the worst kind - Use
defer tx.Rollback()for transactions — it’s a no-op afterCommit()