Question

Go Nil Pointer Dereference: Handle HTTP Errors Before Using Responses

Question

My Go program panics with the following error when it runs on a machine that cannot reach the API servers:

panic: runtime error: invalid memory address or nil pointer dereference

The stack trace points to getBody, which sends an HTTP request with client.Do(req). I expected the function to return an error when the API server is unreachable. Why does it panic instead, and how should the HTTP response and error be handled safely?

res, err := client.Do(req)
defer res.Body.Close()

if err != nil {
    return nil, err
}

Short Answer

You will learn what a nil pointer dereference is in Go, why http.Client.Do can return a nil response when it returns an error, and why errors must be checked before accessing or deferring work on returned pointer values. You will also see a safer HTTP helper function and related validation practices.

Concept

A nil pointer dereference happens when code tries to access data through a pointer whose value is nil.

In this case:

res, err := client.Do(req)
defer res.Body.Close()

client.Do(req) returns two values:

func (c *Client) Do(req *Request) (*Response, error)

If the server cannot be reached—for example, due to a refused connection, missing network route, or DNS failure—err is non-nil. In this failure case, res is typically nil.

The next line immediately evaluates res.Body.Close() in order to register the deferred call. Because res is nil, accessing res.Body panics before the later if err != nil check can run.

The important rule is:

Check an error before using another return value that may be invalid when that error is non-nil.

This is especially common with functions returning a pointer and an error, such as HTTP requests, file opening, database queries, and network connections.

Mental Model

Think of res as a package delivered by a courier and err as the delivery status.

  • When delivery succeeds, you receive a package (res) and no problem (err == nil).
  • When delivery fails, there is no package (res == nil) and the courier gives you an explanation (err != nil).

Calling res.Body.Close() before inspecting err is like trying to open a package before checking whether it was delivered. If no package exists, the program crashes.

Check the delivery status first. Only then open and later close the package.

Take Quiz

Syntax and Examples

Functions often return a value plus an error:

value, err := someOperation()
if err != nil {
    return err
}

// It is safe to use value here.
use(value)

For an HTTP request, the safe ordering is:

res, err := client.Do(req)
if err != nil {
    return nil, err
}
defer res.Body.Close()

After err is checked, res can safely be used. Deferring res.Body.Close() is still important: it ensures that the response body is closed when the function returns, whether reading succeeds or fails.

A complete small example:

package main

import (
    "fmt"
    "net/http"
)

func statusCode(url string) (int, error) {
    res, err := http.Get(url)
    if err != nil {
        return 0, fmt.Errorf("request failed: %w", err)
    }
    defer res.Body.Close()

    return res.StatusCode, nil
}

func main() {
    code, err := statusCode("http://127.0.0.1:9999")
    if err != nil {
        fmt.Println(err)
        return
    }

    fmt.Println("Status:", code)
}

If nothing is listening on port 9999, the program prints an error rather than panicking.

Step by Step Execution

Consider this safe version:

res, err := client.Do(req)
if err != nil {
    return nil, err
}
defer res.Body.Close()

bodyBytes, err := io.ReadAll(res.Body)
if err != nil {
    return nil, err
}

When the API server is unreachable:

  1. client.Do(req) attempts to create a network connection.
  2. The connection fails.
  3. client.Do returns res == nil and a non-nil err.
  4. The if err != nil condition is true.
  5. The function returns that error to its caller.
  6. defer res.Body.Close() is never reached, so no nil pointer is accessed.

When the API server responds successfully:

  1. client.Do(req) returns a non-nil res and err == nil.
  2. The error check is skipped.
  3. defer res.Body.Close() is registered.
  4. The body is read.
  5. When the function returns, res.Body.Close() runs automatically.

In contrast, the original order evaluates res.Body.Close() immediately after client.Do, before it knows whether res exists.

Real World Use Cases

This pattern applies anywhere an operation can fail while returning a value.

  • HTTP APIs: Check the error before reading or closing res.Body.
  • Files: Check the error from os.Open before calling methods on the returned file.
  • Databases: Check errors from connection or query operations before using returned objects.
  • Network sockets: Check errors from net.Dial before reading from or writing to a connection.
  • JSON data: Validate that arrays contain required elements before using indexes such as items[0].
  • External commands: Handle the error from cmd.Run() so callers can decide whether to retry, log, or stop.

For services that depend on remote systems, good error handling turns network outages into useful messages instead of application crashes.

Take Quiz

Real Codebase Usage

In production Go code, developers usually combine early returns, cleanup, context, and descriptive wrapped errors.

func fetchBody(ctx context.Context, client *http.Client, method, url string, headers map[string]string, body []byte) ([]byte, error) {
    req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
    if err != nil {
        return nil, fmt.Errorf("create request: %w", err)
    }

    for key, value := range headers {
        req.Header.Set(key, value)
    }

    res, err := client.Do(req)
    if err != nil {
        return nil, fmt.Errorf("send %s request to %s: %w", method, url, err)
    }
    defer res.Body.Close()

    responseBody, err := io.ReadAll(res.Body)
    if err != nil {
        return nil, fmt.Errorf("read response body: %w", err)
    }

    if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices {
        return nil, fmt.Errorf("unexpected HTTP status %s: %s", res.Status, string(responseBody))
    }

    return responseBody, nil
}

Common project-level practices include:

  • Use early returns to keep the successful path unindented.
  • Use %w with fmt.Errorf so callers can inspect wrapped errors using errors.Is or errors.As.
  • Configure an HTTP client timeout; otherwise some requests can wait too long.
  • Use context.Context to allow cancellation and request deadlines.
  • Treat every 2xx status code as successful unless the API specifically requires only 200 OK.
  • Read an error response body when useful, because APIs often include diagnostic JSON or text.

Common Mistakes

Deferring cleanup before checking the error

Broken:

res, err := client.Do(req)
defer res.Body.Close()
if err != nil {
    return nil, err
}

Fix:

res, err := client.Do(req)
if err != nil {
    return nil, err
}
defer res.Body.Close()

Checking the wrong error

The original logic checks err in an else if after the request has already succeeded:

if res.StatusCode == 200 {
    bodyBytes, err = ioutil.ReadAll(res.Body)
} else if err != nil {
    return nil, err
}

That else if err != nil does not check an HTTP status error. It checks the old error from client.Do, which is already nil if execution reached res.StatusCode.

Read the body first, check the read error, then evaluate the status:

bodyBytes, err := io.ReadAll(res.Body)
if err != nil {
    return nil, err
}

if res.StatusCode != http.StatusOK {
    return nil, fmt.Errorf("unexpected HTTP status: %s", res.Status)
}

Creating a slice with length when only capacity is intended

This creates ten actual empty-string elements:

imageList := make([]string, 10)

Appending IDs adds them after those ten empty strings. Use length zero and capacity ten instead:

imageList := make([]string, 0, 10)

Indexing an empty decoded array

This can panic if the API returns no auth entries:

authToken := keystone.Auth[0].Token.ID

Validate the length first:

if len(keystone.Auth) == 0 {
    return "", fmt.Errorf("authentication response contains no auth entry")
}
authToken := keystone.Auth[0].Token.ID

Ignoring an error from an earlier call

After getImages, check its error before queueing images:

imageList, err := getImages(authToken)
if err != nil {
    return
}

err = queueImages(hostname, imageList)

Otherwise, assigning err = queueImages(...) overwrites any failure from getImages.

Comparisons

SituationResult value on failureCorrect approach
client.Do(req) cannot reach serverUsually nil responseCheck err before using res
os.Open(path) cannot open a fileUsually nil fileCheck err before defer file.Close()
json.Unmarshal receives invalid JSONTarget may be partially populatedCheck err before trusting decoded data
slice[0] on an empty sliceNo error value is returnedCheck len(slice) > 0 first
TechniqueBest useNotes
if err != nil { return ... }Expected operational failuresStandard Go error-handling pattern
panic(...)Broken program invariants or unrecoverable startup failuresUsually not appropriate for unavailable APIs
defer resource.Close()Cleanup after successful acquisitionPlace it only after acquisition succeeds
recover()Rare boundary-level panic handlingDoes not replace normal error checks
Take Quiz

Cheat Sheet

result, err := operation()
if err != nil {
    return err
}
// result is safe to use here
res, err := client.Do(req)
if err != nil {
    return nil, fmt.Errorf("request failed: %w", err)
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
    return nil, fmt.Errorf("read body: %w", err)
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
    return nil, fmt.Errorf("unexpected status: %s", res.Status)
}
// Empty slice with room for 10 values:
values := make([]string, 0, 10)
// Safe before accessing items[0]:
if len(items) == 0 {
    return err
}

Rules:

  • Check err immediately after a call that returns one.
  • Do not access a pointer result until its error has been checked.
  • Defer cleanup only after the resource was acquired successfully.
  • Check errors from body reads separately from request errors.
  • Validate slice lengths before indexing.

FAQ

What causes a nil pointer dereference in Go?

It occurs when code accesses a field or method through a nil pointer, such as res.Body when res is nil.

Can http.Client.Do return both a response and an error?

For normal transport failures, treat a non-nil error as meaning the response must not be used. Check the error first. In Go's HTTP API, a non-nil response with an error is only relevant in limited redirect-related cases, and the response body is already closed.

Why does defer res.Body.Close() panic before the function returns?

The expression res.Body.Close() must be evaluated when the defer statement is encountered. Accessing res.Body therefore panics immediately if res is nil.

Should I close an HTTP response body in Go?

Yes. Once client.Do succeeds, use defer res.Body.Close() to release associated resources.

Does an HTTP 404 or 500 make client.Do return an error?

No. A server response with status 404 or 500 generally produces a non-nil response and a nil transport error. Check res.StatusCode separately.

Why is make([]string, 10) a problem when collecting values?

It creates a slice that already contains ten empty strings. Use make([]string, 0, 10) when you want an empty slice with capacity for ten entries.

How should Go errors be wrapped?

Use %w when adding context to an error:

return fmt.Errorf("get token: %w", err)

This preserves the original error for inspection.

Take Quiz

Mini Project

Description

Build a small Go function that fetches a JSON list of tasks from an HTTP endpoint. It demonstrates safe request creation, HTTP error handling, response-body cleanup, status validation, JSON decoding, and checking data before use.

Goal

Return task titles from a JSON API without panicking when the server is unavailable, returns an error status, or sends invalid data.

Requirements

Create a GET request to a configurable URL. Return a descriptive error if the request cannot be sent. Close the response body only after a successful request. Reject non-2xx HTTP status codes. Decode a JSON array and return the task titles. Use an HTTP client with a timeout.

Take Quiz

Keep learning

Related questions

View more