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
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.
JSON POST
For JSON, marshal the value and create the request explicitly:
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:
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:
For request-specific cancellation:
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:
For example:
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:
The extra byte distinguishes:
Using only:
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:
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:
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:
This matters for resources such as pipes, decoders, or custom readers that have their own lifecycle.
For a file:
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:
For an arbitrary io.Reader, those values are not automatically available.
An *os.File is an important example:
So this:
does not automatically make the request a known-length or replayable request.
If the server requires a known Content-Length, determine the size explicitly:
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:
Conceptually:
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:
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:
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:
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:
the redirect can fail with:
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:
A retry can therefore execute the operation twice.
Two separate questions must be answered:
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 Requests502 Bad Gateway503 Service Unavailable504 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:
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:
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:
From the Transport's retry perspective, these methods are treated as inherently idempotent:
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:
If resp is nil, this panics.
The safe pattern is:
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
Transport success and HTTP success are different things.
Forgetting Content-Type
For JSON:
The server should not have to guess the request format.
Silently truncating the response
Avoid:
when exceeding maxSize must be detected.
Use maxSize+1 and check the result.
Reading the body twice
This does not work:
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:
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
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:
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:
Before sending a POST, ask:
- What format is the body?
- How large can the request and response become?
- Can the request body be replayed safely?
- Can this operation safely happen twice?
The last two questions are where a simple POST becomes a production problem.