• English
  • Example: HTTP POST

    POST sends a request body to an HTTP server.

    The basic call is simple. The production boundaries are not:

    • an HTTP response is separate from a transport error
    • response bodies must be closed and should usually be bounded
    • request bodies have ownership and replayability rules
    • redirects can change the method or require replaying the body
    • retries can duplicate a non-idempotent operation
    • large or streaming bodies may have unknown length

    This example focuses on those boundaries.

    Quick Example

    package main
    
    import (
    	"bytes"
    	"fmt"
    	"io"
    	"net/http"
    )
    
    func main() {
    	payload := []byte(`{"name":"Alice"}`)
    
    	resp, err := http.Post(
    		"https://example.com/api/users",
    		"application/json",
    		bytes.NewReader(payload),
    	)
    	if err != nil {
    		fmt.Println(err)
    		return
    	}
    	defer resp.Body.Close()
    
    	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
    		// Best effort: help connection reuse for a small response.
    		_, _ = io.CopyN(io.Discard, resp.Body, 4<<10)
    		fmt.Printf("unexpected HTTP status: %s\n", resp.Status)
    		return
    	}
    
    	const maxResponseSize = 1 << 20 // 1 MiB
    
    	body, err := io.ReadAll(
    		io.LimitReader(resp.Body, maxResponseSize+1),
    	)
    	if err != nil {
    		fmt.Println(err)
    		return
    	}
    
    	if len(body) > maxResponseSize {
    		fmt.Println("response body too large")
    		return
    	}
    
    	fmt.Println(string(body))
    }

    This is a minimal example. Production code should normally use a reusable http.Client with an appropriate timeout and a request context.

    When to Use POST

    POST is commonly used to:

    • create a resource
    • submit structured data
    • upload data
    • trigger an operation
    • submit a form

    POST does not imply JSON.

    The request body format is determined by Content-Type.

    BodyContent-Type
    JSONapplication/json
    URL-encoded formapplication/x-www-form-urlencoded
    Multipart formmultipart/form-data
    Binary dataapplication-specific media type

    JSON POST

    For JSON, marshal the value and create the request explicitly:

    payload := struct {
    	Name string `json:"name"`
    }{
    	Name: "Alice",
    }
    
    data, err := json.Marshal(payload)
    if err != nil {
    	return err
    }
    
    req, err := http.NewRequest(
    	http.MethodPost,
    	"https://example.com/api/users",
    	bytes.NewReader(data),
    )
    if err != nil {
    	return err
    }
    
    req.Header.Set("Content-Type", "application/json")
    
    resp, err := client.Do(req)
    if err != nil {
    	return err
    }
    defer resp.Body.Close()

    Using an explicit http.Client is deliberate. It gives the caller control over timeouts, transports, redirects, and connection reuse.

    Create the client once and reuse it rather than constructing one for every request.

    Form POST

    For a simple URL-encoded form, http.PostForm is convenient:

    form := url.Values{}
    form.Set("email", "alice@example.com")
    form.Set("password", "secret")
    
    resp, err := http.PostForm(
    	"https://example.com/register",
    	form,
    )
    if err != nil {
    	return err
    }
    defer resp.Body.Close()

    PostForm uses http.DefaultClient, and this example omits status and response-body handling for brevity.

    The same timeout and response-handling concerns apply as with http.Post.

    PostForm is for application/x-www-form-urlencoded. For multipart/form-data, construct the request with multipart.Writer instead.

    Use a Client When You Need Control

    For production requests, use a reusable client:

    client := &http.Client{
    	Timeout: 10 * time.Second,
    }

    For request-specific cancellation:

    req, err := http.NewRequestWithContext(
    	ctx,
    	http.MethodPost,
    	url,
    	bytes.NewReader(data),
    )
    if err != nil {
    	return err
    }
    
    resp, err := client.Do(req)
    if err != nil {
    	return err
    }
    defer resp.Body.Close()

    Client.Timeout applies to the whole exchange, including reading the response body.

    A request context lets the caller stop the request when the operation is cancelled or an upstream deadline expires.

    These examples show timeout and cancellation only. Production code still needs status handling and response-size limits.

    Always Check the HTTP Status

    client.Do returning nil for err means the HTTP exchange completed at the transport level.

    It does not mean the application request succeeded.

    Think about the result as two separate layers:

    client.Do(req)
    
        ├── err != nil
        │      transport-level failure
    
        └── err == nil
    
               └── resp.StatusCode
                      ├── 2xx → HTTP-level success
                      └── other → HTTP-level failure

    For example:

    resp, err := client.Do(req)
    if err != nil {
    	return err
    }
    defer resp.Body.Close()
    
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
    	return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
    }

    A 2xx response can still contain an application-level error. HTTP status handling is therefore necessary, but it is not a substitute for interpreting the API response.

    Read the Response Safely

    Do not turn an untrusted response into an unbounded memory allocation.

    A common pattern is to read one byte beyond the allowed size:

    const maxResponseSize = 1 << 20 // 1 MiB
    
    body, err := io.ReadAll(
    	io.LimitReader(resp.Body, maxResponseSize+1),
    )
    if err != nil {
    	return err
    }
    
    if len(body) > maxResponseSize {
    	return fmt.Errorf("response body exceeds %d bytes", maxResponseSize)
    }

    The extra byte distinguishes:

    exactly 1 MiB    → accepted
    more than 1 MiB  → detected

    Using only:

    io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))

    would silently truncate a larger response.

    If you decode directly with something such as json.Decoder, remember that the maxSize+1 trick does not automatically detect oversized responses. A decoder can stop after the first complete JSON value and leave additional data unread. If the total response size must be enforced, consume and validate the remaining data explicitly.

    Also remember that resp.Body is a stream. Once you read it, the bytes are consumed:

    read body
    
    bytes are gone
    
    a second read sees EOF

    If the body is needed both for logging and decoding, buffer it once within a size limit and reuse the resulting bytes.

    Request Body Ownership

    Client.Do causes the underlying transport to close Request.Body after the request is sent, including error paths.

    But this does not mean that every underlying resource is automatically closed.

    Consider a plain io.Reader:

    req, err := http.NewRequest(
    	http.MethodPost,
    	url,
    	reader,
    )

    If reader does not implement io.ReadCloser, NewRequest wraps it in io.NopCloser.

    The transport closes Request.Body, but that close is only a no-op wrapper close.

    It does not close the underlying resource.

    The ownership model is therefore:

    Before Do:
        caller owns the body
    
    After Do:
        Transport closes Request.Body
    
    But:
        if Request.Body is an io.NopCloser wrapper,
        closing it does not close the underlying reader/resource

    This matters for resources such as pipes, decoders, or custom readers that have their own lifecycle.

    For a file:

    file, err := os.Open("data.json")
    if err != nil {
    	return err
    }
    defer file.Close()
    
    req, err := http.NewRequest(
    	http.MethodPost,
    	url,
    	file,
    )
    if err != nil {
    	return err
    }
    
    resp, err := client.Do(req)
    if err != nil {
    	return err
    }
    defer resp.Body.Close()

    The caller's defer file.Close() covers the path where request construction fails before Do.

    After Do, the transport also closes the request body. For *os.File, the second Close is harmless.

    ContentLength and GetBody

    ContentLength and GetBody solve different problems.

    ContentLength describes the size of the request body.

    GetBody provides a way to create a fresh body so the request can be sent again.

    http.NewRequest automatically sets these fields for these common in-memory body types:

    *bytes.Buffer
    *bytes.Reader
    *strings.Reader

    For an arbitrary io.Reader, those values are not automatically available.

    An *os.File is an important example:

    os.File
        ├── seekable
        ├── NewRequest does not automatically set ContentLength
        └── NewRequest does not set GetBody

    So this:

    file, err := os.Open("video.mp4")
    if err != nil {
    	return err
    }
    defer file.Close()
    
    req, err := http.NewRequest(
    	http.MethodPost,
    	url,
    	file,
    )
    if err != nil {
    	return err
    }

    does not automatically make the request a known-length or replayable request.

    If the server requires a known Content-Length, determine the size explicitly:

    info, err := file.Stat()
    if err != nil {
    	return err
    }
    
    req.ContentLength = info.Size()

    This still does not make the body replayable. GetBody remains nil.

    For an unknown-length body, HTTP/1.1 can use chunked transfer encoding. HTTP/2 has no chunked transfer encoding; the body is carried in DATA frames instead.

    Making a Body Replayable

    GetBody is the mechanism used when Go needs a fresh copy of the request body.

    For example, bytes.NewReader gives NewRequest enough information to create one:

    req, err := http.NewRequest(
    	http.MethodPost,
    	url,
    	bytes.NewReader(data),
    )
    if err != nil {
    	return err
    }

    Conceptually:

    original body
    
    GetBody()
    
    new body starting from byte 0

    A custom GetBody implementation must return a new, independently readable body each time.

    It must not return the already-consumed body.

    For a file, replayability can be implemented explicitly:

    req.GetBody = func() (io.ReadCloser, error) {
    	f, err := os.Open("video.mp4")
    	if err != nil {
    		return nil, err
    	}
    	return f, nil
    }

    Each call opens a new file descriptor positioned at the beginning.

    For a custom body backed by a seekable resource, the same idea can be implemented with Seek, provided concurrent use and ownership are handled correctly.

    The important property is not merely "seekable." It is:

    Can I produce a fresh body containing exactly the same bytes?

    That is what retries and redirects need.

    Large Request Bodies

    For large uploads, stream the request body instead of first loading the entire file into memory:

    file, err := os.Open("video.mp4")
    if err != nil {
    	return err
    }
    defer file.Close()
    
    info, err := file.Stat()
    if err != nil {
    	return err
    }
    
    req, err := http.NewRequest(
    	http.MethodPost,
    	"https://example.com/upload",
    	file,
    )
    if err != nil {
    	return err
    }
    
    req.ContentLength = info.Size()
    req.Header.Set("Content-Type", "application/octet-stream")
    
    resp, err := client.Do(req)
    if err != nil {
    	return err
    }
    defer resp.Body.Close()

    Here Content-Length is known because the application explicitly obtained the file size and assigned it to the request.

    Without that assignment, NewRequest does not infer the file length from *os.File.

    The request is still not automatically replayable because GetBody is not set.

    For arbitrary streaming sources such as pipes, the length may genuinely be unknown. Over HTTP/1.1, that can result in chunked transfer encoding.

    If request data originates from untrusted input, bound it before constructing an in-memory body. A response-size limit protects the client from a large response; it does not protect the client from creating an unnecessarily large request in memory.

    POST and Redirects

    Redirects deserve special attention with POST because the client may change the method or need to replay the request body.

    Go's HTTP client follows these rules:

    301 / 302 / 303
        POST → GET
        request body is dropped
    
    307 / 308
        POST → POST
        request body is preserved
        body must be replayable

    For example, if a POST is sent to an HTTP URL and the server responds with a 301 redirect to HTTPS, Go's client follows the redirect with a GET.

    The original POST body is not sent to the redirected URL.

    For 307 and 308, the method and body are preserved. The client therefore needs a fresh copy of the body.

    If the body is not replayable, for example:

    http.NewRequest(http.MethodPost, url, someStreamingReader)

    the redirect can fail with:

    http: cannot rewind body

    This is particularly relevant to file and streaming uploads.

    If a POST endpoint may redirect:

    • prefer the final URL directly when possible
    • verify the redirect behavior explicitly
    • make sure the body is replayable when 307/308 redirects are expected

    Do not treat redirects as an invisible transport detail for POST.

    POST and Retries

    POST commonly represents an operation with side effects:

    POST /payments
    POST /orders
    POST /emails
    POST /jobs

    A retry can therefore execute the operation twice.

    Two separate questions must be answered:

    Should we retry?
            +
    Is retrying safe?

    They are not the same question.

    Even when an operation is idempotent, not every failure is worth retrying.

    Typical application-level retry candidates include:

    • temporary network failures
    • 429 Too Many Requests
    • 502 Bad Gateway
    • 503 Service Unavailable
    • 504 Gateway Timeout

    A 429 response may include Retry-After, which the retry policy should respect.

    Retries should also remain inside the original context deadline.

    For operations that must tolerate retries, use an application-level idempotency mechanism when the API provides one:

    Idempotency-Key: 8f7c...

    The server must implement the semantics. A client cannot make an operation idempotent merely by adding a header.

    For example, an API might associate the key with the original result:

    first request
    
    Idempotency-Key: abc
    
    process payment
    
    store result for "abc"
    
    retry
    
    Idempotency-Key: abc
    
    return the stored result

    This is especially important when a timeout occurs after the server may already have processed the request.

    Go's Transport Also Has Retry Rules

    Go's Transport has its own limited retry behavior.

    The retry paths are different.

    On a brand-new connection, Go can retry when the failure happened before any request bytes were written. There is nothing to replay.

    On a reused connection, Go can retry when the request is replayable and the connection appears to have been closed by the server. A classic case is:

    client has an idle connection
    
    server closes the idle connection
    
    client picks that connection for a new request
    
    request is written / response read fails
    
    Transport retries when the request is replayable

    From the Transport's retry perspective, these methods are treated as inherently idempotent:

    GET
    HEAD
    OPTIONS
    TRACE

    A request can also be treated as idempotent when it carries an Idempotency-Key or X-Idempotency-Key.

    For a request with a body, GetBody is important because the transport needs a fresh body when replaying the request.

    It is tempting to summarize this as:

    "POST is never automatically retried."

    That is too strong.

    The practical rule is:

    Do not rely on Transport retries to make POST safe. If a POST can be retried, make the operation explicitly idempotent.

    Transport-level retry behavior and application-level retry policy are separate concerns.

    Response Errors and resp

    Do not blindly defer a response body close before checking the error:

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

    If resp is nil, this panics.

    The safe pattern is:

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

    There is one subtle case worth knowing: Do can return a non-nil Response together with an error when redirect processing fails. In that case, the returned response body has already been closed by net/http, so callers should not attempt to use it as a normal response.

    For ordinary request failures, handle the error first and only treat a successfully returned response as a body you need to process.

    Common Mistakes

    Assuming err == nil means success

    resp, err := client.Do(req)
    if err != nil {
    	return err
    }
    
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
    	return fmt.Errorf("unexpected status: %s", resp.Status)
    }

    Transport success and HTTP success are different things.

    Forgetting Content-Type

    For JSON:

    req.Header.Set("Content-Type", "application/json")

    The server should not have to guess the request format.

    Silently truncating the response

    Avoid:

    io.ReadAll(io.LimitReader(resp.Body, maxSize))

    when exceeding maxSize must be detected.

    Use maxSize+1 and check the result.

    Reading the body twice

    This does not work:

    body, _ := io.ReadAll(resp.Body)
    
    // Later:
    json.NewDecoder(resp.Body).Decode(&result)

    The second read starts where the first one ended.

    Buffer once when the same body must be used for multiple purposes.

    Forgetting to close the response body

    Use:

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

    Assuming *os.File is automatically replayable

    It is seekable, but NewRequest does not automatically create a GetBody function for it.

    Known length and replayability are separate properties.

    Blindly retrying POST

    A client timeout does not prove that the server did not process the request.

    Retry only when the operation and API contract make the retry safe.

    Using http.DefaultClient in library code

    Convenience functions such as http.Post and http.PostForm use http.DefaultClient.

    That is not only a timeout concern. DefaultClient is a process-wide shared variable whose configuration can be changed.

    Library code should generally accept or own an explicit client instead of relying on global client state.

    Sending user-controlled URLs

    If the destination URL can come from an untrusted user, POST can become an SSRF primitive.

    The server may be induced to send POST requests and request bodies to internal services.

    Do not treat "it's only a POST client" as a security boundary. Validate and restrict destinations when URLs are externally controlled.

    Production Example

    package api
    
    import (
    	"bytes"
    	"context"
    	"encoding/json"
    	"fmt"
    	"io"
    	"net/http"
    )
    
    const maxResponseSize = 1 << 20 // 1 MiB
    
    func CreateUser(
    	ctx context.Context,
    	client *http.Client,
    	name string,
    ) ([]byte, error) {
    	payload := struct {
    		Name string `json:"name"`
    	}{
    		Name: name,
    	}
    
    	data, err := json.Marshal(payload)
    	if err != nil {
    		return nil, fmt.Errorf("marshal user: %w", err)
    	}
    
    	req, err := http.NewRequestWithContext(
    		ctx,
    		http.MethodPost,
    		"https://example.com/api/users",
    		bytes.NewReader(data),
    	)
    	if err != nil {
    		return nil, fmt.Errorf("create request: %w", err)
    	}
    
    	req.Header.Set("Content-Type", "application/json")
    
    	resp, err := client.Do(req)
    	if err != nil {
    		return nil, fmt.Errorf("POST user: %w", err)
    	}
    	defer resp.Body.Close()
    
    	if resp.ContentLength > maxResponseSize {
    		// The body is known to be oversized. Do not drain it.
    		// Losing this connection is an intentional trade-off.
    		return nil, fmt.Errorf(
    			"response body too large: %d bytes",
    			resp.ContentLength,
    		)
    	}
    
    	body, err := io.ReadAll(
    		io.LimitReader(resp.Body, maxResponseSize+1),
    	)
    	if err != nil {
    		return nil, fmt.Errorf("read response: %w", err)
    	}
    
    	if len(body) > maxResponseSize {
    		return nil, fmt.Errorf(
    			"response body exceeds %d bytes",
    			maxResponseSize,
    		)
    	}
    
    	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
    		return nil, fmt.Errorf(
    			"unexpected HTTP status: %s: %s",
    			resp.Status,
    			safeErrorBody(body, 4<<10),
    		)
    	}
    
    	return body, nil
    }
    
    func safeErrorBody(body []byte, max int) []byte {
    	if len(body) <= max {
    		return body
    	}
    
    	return append(append([]byte(nil), body[:max]...), "..."...)
    }

    The example deliberately reads a bounded response before checking the status.

    An error response can contain useful diagnostics, and consuming a bounded body can improve connection reuse.

    The error body is truncated before being included in the returned error. Server responses can contain sensitive fields, stack traces, or other data that should not be copied wholesale into logs.

    There is another valid strategy: check the status first and drain only a small amount when the error body is not needed. Choose based on the API and expected response size.

    Rule of Thumb

    For a JSON POST:

    json.Marshal
    
    http.NewRequestWithContext
    
    Content-Type
    
    client.Do
    
    register defer resp.Body.Close()
    
    bounded response read
    
    HTTP status check
    
    return response

    defer resp.Body.Close() is registered immediately after a successful Do; the actual close happens when the function returns.

    For a large or streaming request:

    stream the body
    
    is ContentLength known?
    
    is the body replayable?
    
    can the endpoint redirect?
    
    can the operation tolerate retry?

    Before sending a POST, ask:

    1. What format is the body?
    2. How large can the request and response become?
    3. Can the request body be replayed safely?
    4. Can this operation safely happen twice?

    The last two questions are where a simple POST becomes a production problem.