Skip to content

Table of Contents

Go Backend & Databases — Interview Questions

Questions covering REST, gRPC, the net/http server, database/sql, connection pooling, transactions, Redis, caching, and indexing. Difficulty is marked per question: 🟢 junior · 🟡 mid · 🔴 senior.

Table of Contents

HTTP Server Internals

🟢 What is the http.Handler interface and how does ServeMux fit in? `http.Handler` is the core server abstraction with one method: `ServeHTTP(w http.ResponseWriter, r *http.Request)`. Anything implementing it can serve requests, and `http.HandlerFunc` adapts a plain function to the interface. `http.ServeMux` is itself a `Handler` that acts as a request router (multiplexer): it matches the request path against registered patterns and dispatches to the matching handler. Since Go 1.22 the default mux supports method and wildcard patterns like `GET /items/{id}`.
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("ok"))
})
🟡 How does Go's net/http server handle concurrency? The `net/http` server accepts connections in a loop and spawns **one goroutine per connection**. Each connection goroutine reads requests and invokes your handler, so handlers run concurrently and must be safe for concurrent use of any shared state. Because goroutines are cheap, this model scales to many thousands of simultaneous connections without a thread pool. The flip side is that a handler that blocks (e.g. on a slow DB call without timeouts) ties up a goroutine, so you should always use context-aware, bounded operations.
🔴 How do you implement graceful shutdown of an HTTP server? Call `srv.Shutdown(ctx)` instead of letting the process die abruptly. `Shutdown` stops accepting new connections, then waits for in-flight requests to finish up to the context deadline, returning early if the context expires. Typically you listen for SIGINT/SIGTERM, then call Shutdown with a timeout context so long-running requests get a grace period but can't hang shutdown forever. Note that `ListenAndServe` returns `http.ErrServerClosed` when Shutdown is called, which you should treat as a clean exit.
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(ctx)
🟡 Why should you set ReadTimeout and WriteTimeout on http.Server? The zero-value `http.Server` has **no timeouts**, meaning a slow or malicious client can hold a connection (and its goroutine) open indefinitely — a Slowloris-style denial of service. `ReadTimeout` bounds how long reading the entire request (including body) may take, `WriteTimeout` bounds writing the response, and `IdleTimeout` bounds keep-alive idle time. For finer control over just the headers use `ReadHeaderTimeout`. Setting sensible timeouts is a basic production hardening step.
srv := &http.Server{
    Addr:         ":8080",
    ReadTimeout:  5 * time.Second,
    WriteTimeout: 10 * time.Second,
    IdleTimeout:  60 * time.Second,
}
🟡 How does request context cancellation work when a client disconnects? Every `*http.Request` carries a `Context()` that the server cancels automatically when the client disconnects or the request times out. If you propagate that context into downstream calls (DB queries, outbound HTTP, etc.), they will be canceled promptly when the client goes away, freeing resources instead of doing wasted work. This is why you should thread `r.Context()` through your whole call stack rather than using `context.Background()` inside handlers.
func handler(w http.ResponseWriter, r *http.Request) {
    rows, err := db.QueryContext(r.Context(), "SELECT ...")
    _ = rows; _ = err
}
🔴 What is middleware in Go HTTP servers and how is it typically composed? Middleware is a function that wraps an `http.Handler` to add cross-cutting behavior — logging, auth, metrics, recovery, compression — and returns a new `http.Handler`. Because the wrapping function has the signature `func(http.Handler) http.Handler`, you can chain them so each runs before/after the next, forming an onion. Composition is just nested calls (or a small helper that folds a slice), and order matters: recovery should be outermost so it catches panics from inner layers.
func Logging(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 %s", r.Method, r.URL.Path, time.Since(start))
    })
}

REST API Design

🟢 What makes an API RESTful, and how do HTTP methods map to operations? REST models server state as **resources** addressed by URLs, manipulated through a uniform set of HTTP methods, and ideally stateless between requests. The common mapping is: `GET` to read (safe, idempotent), `POST` to create, `PUT` to replace/upsert (idempotent), `PATCH` to partially update, and `DELETE` to remove (idempotent). Status codes communicate outcome: 200/201/204 for success, 400/404/409 for client errors, 5xx for server errors. Good REST APIs use nouns for paths (`/users/123/orders`) and reserve verbs for the method, not the URL.
🟡 What does idempotency mean and why does it matter for REST and retries? An operation is idempotent if performing it multiple times has the same effect as performing it once. `GET`, `PUT`, and `DELETE` are expected to be idempotent, while `POST` generally is not (two POSTs create two resources). This matters because networks are unreliable: clients and proxies retry failed requests, and idempotent operations are safe to retry without side effects. For non-idempotent operations you can add an **idempotency key** (a client-supplied unique header) so the server deduplicates retries — payment APIs do this universally.
🟡 How would you design pagination for a REST list endpoint? Two main strategies exist. **Offset/limit** (`?offset=40&limit=20`) is simple but degrades on large offsets (the DB must scan and skip rows) and can skip/duplicate items if data changes between pages. **Cursor (keyset) pagination** uses a stable sort key — `WHERE id > $last_id ORDER BY id LIMIT 20` — which stays fast at any depth and is consistent under concurrent inserts, at the cost of not supporting random page jumps. For large or high-write datasets, prefer cursor pagination and return an opaque `next_cursor` token.

gRPC

🟢 What is gRPC and what does it use under the hood? gRPC is a high-performance RPC framework that uses **Protocol Buffers** as its interface definition language and serialization format, and **HTTP/2** as its transport. You define services and messages in a `.proto` file, and a code generator produces strongly-typed client and server stubs in many languages. Protobuf's binary encoding is compact and fast compared to JSON, and HTTP/2 enables multiplexing many calls over one connection. The contract-first `.proto` gives you schema enforcement and easy cross-language interop.
service UserService {
  rpc GetUser(GetUserRequest) returns (User);
}
🟡 What are the four types of gRPC service methods? gRPC supports four call patterns. **Unary** is a single request and single response, like a normal function call. **Server streaming** sends one request and receives a stream of responses (e.g. subscribing to updates). **Client streaming** sends a stream of requests and gets one response (e.g. uploading chunks). **Bidirectional streaming** lets both sides send streams independently over the same connection (e.g. chat). All of these ride on HTTP/2 streams, which is what makes multiplexed streaming natural.
🔴 How do deadlines and context propagation work in gRPC? gRPC is deeply integrated with Go's `context`: every client call takes a context, and a **deadline** set with `context.WithTimeout` is transmitted over the wire to the server. The server sees the remaining time in its handler's context and should propagate that same context to its own downstream calls, so a deadline flows through an entire chain of services. If the deadline is exceeded, the call is canceled end to end and returns `codes.DeadlineExceeded`, avoiding wasted work on results nobody is waiting for. Always set deadlines — an unbounded gRPC call is a reliability hazard.
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
resp, err := client.GetUser(ctx, req)
🟡 When would you choose gRPC over REST/JSON, and what are the tradeoffs? Choose gRPC for internal service-to-service communication where you want low latency, compact binary payloads, strict schemas, code-generated clients, and streaming — it shines in microservice meshes. Choose REST/JSON for public-facing APIs, browser clients (gRPC needs gRPC-Web plus a proxy), and easy human debuggability with curl. gRPC's downsides are that payloads aren't human-readable, tooling/observability is heavier, and proto evolution requires discipline. Many systems do both: gRPC internally, a REST/JSON gateway at the edge.

database/sql & Connection Pooling

🟢 Is sql.DB a single connection? Explain what it actually represents. No — `sql.DB` is a **connection pool**, not a single connection, and it is safe for concurrent use by many goroutines. You typically open it once at program startup and keep it for the lifetime of the application rather than opening one per request. The pool lazily creates connections as needed (`Open` doesn't actually connect; call `Ping` to verify), reuses idle ones, and hands them out per query. Treating it like a single connection or opening/closing it repeatedly is a common and costly mistake.
db, err := sql.Open("postgres", dsn) // does not connect yet
if err := db.Ping(); err != nil {    // forces a real connection
    log.Fatal(err)
}
🟡 What do SetMaxOpenConns, SetMaxIdleConns, and SetConnMaxLifetime control? `SetMaxOpenConns(n)` caps the total open connections (in-use + idle); when the limit is hit, additional queries block until a connection frees up. `SetMaxIdleConns(n)` caps how many idle connections are kept ready for reuse (default is just **2**, which often causes churn under load). `SetConnMaxLifetime(d)` forces connections to be recycled after a maximum age, and `SetConnMaxIdleTime(d)` closes connections that have sat idle too long. Tuning these together prevents both connection exhaustion and the overhead of constantly reopening connections.
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)
🔴 Why is SetConnMaxLifetime important behind a load balancer or proxy? Long-lived pooled connections can become stale: a database failover, a rolling deploy, or a TCP load balancer / proxy that silently drops idle connections can leave the pool holding dead sockets, causing intermittent errors on the next query. `SetConnMaxLifetime` bounds connection age so the pool periodically rotates connections, naturally rebalancing across new backend instances and shedding stale ones. A common value is a few minutes — short enough to recover from topology changes, long enough to avoid excessive reconnection. It also helps DB-side resource cleanup since servers often cap connection age too.
🟢 Why must you always close sql.Rows, and what's the idiomatic pattern? Each open `*sql.Rows` holds a connection from the pool until it is fully drained or explicitly closed; forgetting to close it leaks that connection, and under load the pool exhausts and queries hang. The idiomatic pattern is to `defer rows.Close()` immediately after a successful `Query`, loop with `rows.Next()`, scan, and then check `rows.Err()` after the loop to catch iteration errors. Note that `Close` is safe to call multiple times and `Next` auto-closes on EOF, but the explicit defer protects against early returns/errors.
rows, err := db.Query("SELECT id, name FROM users")
if err != nil { return err }
defer rows.Close()
for rows.Next() {
    var id int; var name string
    if err := rows.Scan(&id, &name); err != nil { return err }
}
return rows.Err()
🟡 When should you use QueryRow versus Query, and how are errors handled? Use `QueryRow` when you expect at most one row — it returns a `*sql.Row` and you call `Scan` directly on it. Crucially, `QueryRow` defers its error to `Scan`, so a no-rows result surfaces as `sql.ErrNoRows` from `Scan`, which you should check explicitly. Use `Query` for multiple rows. `QueryRow` conveniently manages the underlying connection for you (you don't call Close), whereas `Query` requires you to close the returned rows.
var name string
err := db.QueryRow("SELECT name FROM users WHERE id = $1", id).Scan(&name)
if errors.Is(err, sql.ErrNoRows) {
    // not found
}
🟢 How do parameterized queries prevent SQL injection? Parameterized (prepared) queries send the SQL text and the argument values **separately** to the database, so user input is always treated as data, never as executable SQL — there is no way for a value to "break out" and inject commands. In `database/sql` you use placeholders (`?` for MySQL, `$1` for Postgres) and pass args as additional parameters; the driver/DB binds them safely. Never build SQL by string-concatenating user input with `fmt.Sprintf` — that is the classic injection vulnerability.
// Safe:
db.Query("SELECT * FROM users WHERE email = $1", email)
// Unsafe — never do this:
// db.Query("SELECT * FROM users WHERE email = '" + email + "'")
🟡 What are prepared statements in database/sql and when do they help? `db.Prepare` (or `tx.Prepare`) creates a `*sql.Stmt` that sends the SQL to the database once for parsing/planning, then lets you execute it many times with different arguments, saving repeated parse overhead. They also enforce parameterized binding, so they are inherently injection-safe. They help most for hot queries run in a tight loop; for one-off queries the convenience methods (`Query`/`Exec`) are fine and internally use a prepared statement anyway. Remember to `Close()` the statement, and be aware that with a pool a prepared statement may be re-prepared on different connections.
🟡 Why prefer the *Context methods like QueryContext over Query? The context-aware methods (`QueryContext`, `ExecContext`, `QueryRowContext`, `BeginTx`) let the database operation be **canceled** when the context is canceled — for instance when an HTTP client disconnects or a deadline passes. This frees the pooled connection and tells the driver to cancel the in-flight query instead of waiting for a slow result no one needs. In any request-scoped code you should thread `r.Context()` into these methods; the non-context variants are essentially `context.Background()` and can't be canceled.

Transactions

🟢 How do you run a transaction in database/sql and why is the deferred Rollback pattern used? You start with `db.BeginTx(ctx, opts)`, run your statements on the returned `*sql.Tx`, and finish with `Commit`. The idiomatic safety net is `defer tx.Rollback()` right after Begin: if you return early due to an error (or a panic), the rollback fires and releases the transaction; if you reach `Commit` first, the later `Rollback` is a harmless no-op (it returns `sql.ErrTxDone`, which you ignore). This guarantees you never leak an open transaction holding locks and a connection.
tx, err := db.BeginTx(ctx, nil)
if err != nil { return err }
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, "UPDATE accounts SET bal = bal - $1 WHERE id=$2", amt, from); err != nil {
    return err
}
if _, err := tx.ExecContext(ctx, "UPDATE accounts SET bal = bal + $1 WHERE id=$2", amt, to); err != nil {
    return err
}
return tx.Commit()
🔴 Explain SQL isolation levels and the anomalies they prevent. The four standard isolation levels trade consistency against concurrency. **Read Uncommitted** allows dirty reads (seeing another transaction's uncommitted data). **Read Committed** (the common default in Postgres) prevents dirty reads but allows non-repeatable reads. **Repeatable Read** prevents non-repeatable reads (and in Postgres uses snapshot isolation that also blocks phantoms) but classically allows phantom rows. **Serializable** behaves as if transactions ran one at a time, preventing all these anomalies, at the cost of more aborts/retries. In Go you select one via `sql.TxOptions{Isolation: sql.LevelSerializable}` in `BeginTx`.
tx, _ := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
🔴 Why is a sql.Tx bound to a single connection, and what are the implications? A transaction must run on one physical connection because transactional state (locks, the snapshot, pending changes) lives on that connection in the database. So `db.BeginTx` reserves a connection from the pool for the entire life of the `Tx` and returns it only on Commit/Rollback. The implications: you must not run a long transaction that idles, because it withholds a pooled connection; you should keep transactions short; and statements issued through the `Tx` (not the `db`) are the only ones inside that transaction. Also avoid using the same `Tx` from multiple goroutines concurrently since it serializes on that one connection.
🟡 How do you handle errors and retries with serialization failures or deadlocks? With Serializable (and sometimes Repeatable Read) isolation, the database may abort a transaction with a serialization failure (`40001`) or a deadlock, expecting the client to **retry** the whole transaction. So transactional code should be wrapped in a retry loop with a bounded number of attempts and ideally jittered backoff, and the transaction body must be side-effect-free until commit so retrying is safe. Detect the specific SQLSTATE from the driver error rather than retrying all errors, since constraint violations and the like are not retryable.

Redis & Caching

🟢 Why is Redis described as single-threaded, and why is it still fast? Redis executes commands on a **single thread**, processing them one at a time from an event loop, which means each command is effectively atomic — no two commands interleave. It is still extremely fast because operations are in-memory, the data structures are O(1)/O(log n), and avoiding locks/context-switching removes concurrency overhead. Network I/O is handled with multiplexing (and newer versions use I/O threads for reading/writing sockets while command execution stays single threaded). The single-threaded model is also why you must avoid slow O(n) commands like `KEYS` in production — they block everything.
🟢 What are common use cases for Redis beyond simple caching? Redis is a versatile in-memory data store used for: **caching** (the most common), **session storage** for stateless app servers, **rate limiting** (atomic counters with TTL), **distributed locks** (via SETNX), **pub/sub** messaging, **job/queues** (lists or streams), and **leaderboards** using sorted sets (ZSET) for ranked scores. Its rich data types — strings, hashes, lists, sets, sorted sets, streams, HyperLogLog — make it more than a key/value cache. TTL support on any key makes ephemeral data trivial.
🟡 How do you implement a basic distributed lock with SETNX, and what are the gotchas? The classic approach is `SET key value NX PX `: `NX` sets the key only if it doesn't exist (acquiring the lock) and `PX` gives it an expiry so a crashed holder doesn't deadlock the lock forever. To release, you must delete the key only if you still own it — compare a unique token and delete atomically via a Lua script, never a plain `DEL`, otherwise you might delete someone else's lock acquired after your TTL expired. Even then, single-node Redis locks aren't perfectly safe under failover; for stronger guarantees people use the Redlock algorithm or a real consensus system.
SET lock:order:42 <token> NX PX 5000
🟡 Explain cache-aside versus write-through caching. In **cache-aside** (lazy loading), the application checks the cache first; on a miss it reads from the database, populates the cache, and returns the value — the cache is only filled on demand. In **write-through**, every write goes through the cache, which synchronously updates both the cache and the database, keeping them consistent at write time. Cache-aside is simple and resilient (cache failure just means more DB load) but can serve stale data and has cold-miss latency; write-through keeps the cache fresh but adds write latency and caches data that may never be read. Many systems combine cache-aside reads with explicit invalidation on write.
🔴 What is a cache stampede (thundering herd) and how do you prevent it? A cache stampede happens when a popular cached key expires and many concurrent requests all miss simultaneously, then all hammer the database to recompute the same value, potentially overloading it. Mitigations include: a **single-flight** lock so only one request recomputes while others wait or serve stale (Go's `golang.org/x/sync/singleflight` does exactly this); **probabilistic early expiration**, where requests randomly refresh slightly before TTL; serving **stale data** while a background job refreshes; and adding **jitter** to TTLs so keys don't all expire at once. Locking around the recompute is the most direct fix.
v, err, _ := group.Do(key, func() (any, error) {
    return loadFromDB(key) // only one call runs concurrently per key
})
🟡 How does TTL-based expiration work in Redis and what eviction policies exist? You set a TTL on a key with `EXPIRE`/`PEXPIRE` or the `EX`/`PX` options on `SET`; the key is removed when it expires. Redis expires keys both **lazily** (on access) and via a periodic **active** sampling sweep, so memory is reclaimed even for keys that are never read again. Separately, when Redis hits its `maxmemory` limit it applies an eviction policy such as `allkeys-lru`, `allkeys-lfu`, `volatile-ttl`, or `noeviction` (reject writes). Choosing `allkeys-lru`/`lfu` is typical for a pure cache; `noeviction` suits a datastore where losing keys is unacceptable.

SQL Indexing

🟢 What is a database index and what is the default index type? An index is an auxiliary data structure that lets the database find rows matching a predicate without scanning the whole table, dramatically speeding up reads on the indexed columns. The default in most relational databases (Postgres, MySQL/InnoDB) is a **B-tree** (technically B+tree) index, which keeps keys sorted and supports equality and range lookups (`=`, `<`, `>`, `BETWEEN`, prefix `LIKE`) in O(log n). Other types exist for special cases — hash, GIN/GiST for full-text and JSON, BRIN for huge append-only tables.
🟡 Explain the left-prefix rule for composite indexes. A composite index on `(a, b, c)` is sorted first by `a`, then `b`, then `c`, so the database can use it only for query predicates that include a **leading prefix** of those columns. It helps queries filtering on `a`, on `a` and `b`, or on `a`, `b`, and `c` — but it generally cannot be used efficiently for a query that filters only on `b` or only on `c`, because those aren't the leading column. This is why column **order matters**: put the most selective / most frequently filtered columns first, and place equality columns before range columns.
CREATE INDEX idx_orders ON orders (customer_id, status, created_at);
-- uses index: WHERE customer_id = ?  /  WHERE customer_id = ? AND status = ?
-- cannot use efficiently: WHERE status = ?
🔴 What is a covering index and why is it valuable? A covering index is one that contains **all the columns a query needs** (both the filter columns and the selected columns), so the database can answer the query entirely from the index without visiting the table heap — an "index-only scan." This avoids the extra random I/O of fetching full rows and can be a major speedup for hot read paths. In Postgres you can add non-key payload columns with `INCLUDE`; in MySQL InnoDB the secondary index already carries the primary key, and you arrange columns so the index covers the query. The tradeoff is a larger index and more write overhead.
CREATE INDEX idx_cover ON users (email) INCLUDE (name, created_at);
🟡 When do indexes hurt performance, and what's the cost of over-indexing? Indexes speed up reads but **slow down writes**: every `INSERT`, `UPDATE` (of an indexed column), and `DELETE` must also update each affected index, adding I/O and locking. They also consume disk and memory, and the query planner spends time considering more options. On small tables a sequential scan can be faster than an index lookup, so the planner may ignore the index anyway. The lesson is to index for your actual query patterns, drop unused indexes, and avoid redundant ones (e.g. an index on `(a)` is redundant if you already have `(a, b)`).
🔴 Why might the database ignore an index even when one exists? The query planner is cost-based and will skip an index when it estimates a different plan is cheaper or when the index simply can't apply. Common reasons: low **selectivity** (the predicate matches a large fraction of rows, so a sequential scan is cheaper); a function or type mismatch on the column (`WHERE lower(email)=...` won't use a plain index on `email` — you'd need an expression index); a leading wildcard `LIKE '%foo'`; stale statistics making estimates wrong (fixed with `ANALYZE`); or implicit type coercion. Using `EXPLAIN`/`EXPLAIN ANALYZE` reveals the chosen plan and whether the index was used.