Go HTTP GET/POST request
last modified October 30, 2026
In this article we show how to send HTTP GET and POST requests in Go. The examples talk to a small server that is part of the article, so every response is reproducible and no external service is needed.
The HTTP GET method requests a representation of a resource and should only retrieve data. The POST method sends data to the server, typically from a form or a JSON API.
All examples in this article were compiled and run with Go 1.27.0.
The demo server
The server listens on 127.0.0.1:8080. It serves an HTML page at
/, echoes request headers at /headers, query
parameters at /get, form or JSON data at /post,
returns a 404 at /missing, and answers after two seconds at
/slow.
package main
import (
"encoding/json"
"log"
"net/http"
"time"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-Server", "demo")
w.Write([]byte("<!DOCTYPE html>\n<html lang=\"en\">\n<head><title>Demo page</title></head>\n<body><p>Hello from the demo server.</p></body>\n</html>\n"))
})
http.HandleFunc("/headers", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"user-agent": r.UserAgent(),
"x-token": r.Header.Get("X-Token"),
})
})
http.HandleFunc("/get", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(r.URL.Query())
})
http.HandleFunc("/post", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Header.Get("Content-Type") == "application/json" {
var data map[string]any
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
json.NewEncoder(w).Encode(data)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
json.NewEncoder(w).Encode(r.PostForm)
})
http.HandleFunc("/missing", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not found", http.StatusNotFound)
})
http.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
w.Write([]byte("slow response\n"))
})
log.Println("Listening on 127.0.0.1:8080")
log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}
The server echoes what it receives, which makes the effect of each client
example visible. A query to /get with curl confirms that it is
running.
$ curl -s http://127.0.0.1:8080/get?name=John+Doe
{"name":["John Doe"]}
Go GET request
The simplest client uses http.Get, which returns a response with an
already read status line and an open body. The body must be closed, and it is
read with io.ReadAll because ioutil.ReadAll has been
deprecated since Go 1.16.
package main
import (
"fmt"
"io"
"log"
"net/http"
)
func main() {
resp, err := http.Get("http://127.0.0.1:8080/")
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.Status)
fmt.Print(string(body))
}
http.Get follows redirects and returns an error only for transport
failures, not for HTTP error statuses. The status line is available as a string
in resp.Status.
$ go run main.go 200 OK <!DOCTYPE html> <html lang="en"> <head><title>Demo page</title></head> <body><p>Hello from the demo server.</p></body> </html>
Saving the response to a file
For larger bodies it is better to stream the bytes instead of holding them in
memory. os.File implements io.Writer, so
ReadFrom copies the body directly to disk and returns the number of
bytes written.
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func main() {
resp, err := http.Get("http://127.0.0.1:8080/")
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
f, err := os.Create("page.html")
if err != nil {
log.Fatal(err)
}
defer f.Close()
n, err := f.ReadFrom(resp.Body)
if err != nil {
log.Fatal(err)
}
fmt.Println("status:", resp.Status)
fmt.Println("bytes:", n)
}
The file is created before the copy and closed when main returns.
The same job can be done with io.Copy(f, resp.Body), which is what
ReadFrom falls back to.
$ go run main.go status: 200 OK bytes: 127 $ head -2 page.html <!DOCTYPE html> <html lang="en">
GET request with query parameters
Query parameters are collected in a url.Values map and encoded with
Encode, which escapes spaces as + and other special
characters as percent escapes.
package main
import (
"fmt"
"io"
"log"
"net/http"
"net/url"
)
func main() {
params := url.Values{
"name": {"John Doe"},
"occupation": {"gardener"},
}
u := "http://127.0.0.1:8080/get?" + params.Encode()
fmt.Println(u)
resp, err := http.Get(u)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(body))
}
The encoded query is name=John+Doe&occupation=gardener. The
server returns the parameters as a JSON object in which every value is an array,
because a query key may be repeated.
$ go run main.go
http://127.0.0.1:8080/get?name=John+Doe&occupation=gardener
{"name":["John Doe"],"occupation":["gardener"]}
Custom request headers
http.Get cannot set headers. The request is built with
http.NewRequest and sent with a client, which allows any header to
be added. Header.Set replaces an existing value.
package main
import (
"fmt"
"io"
"log"
"net/http"
)
func main() {
req, err := http.NewRequest("GET", "http://127.0.0.1:8080/headers", nil)
if err != nil {
log.Fatal(err)
}
req.Header.Set("User-Agent", "Go client example/1.0")
req.Header.Set("X-Token", "secret-token")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.Request.UserAgent())
fmt.Println(string(body))
}
The server echoes both headers back. Reading
resp.Request.UserAgent() shows the value that was actually sent,
which is useful when a transport adds or rewrites headers.
$ go run main.go
Go client example/1.0
{"user-agent":"Go client example/1.0","x-token":"secret-token"}
POST request with form data
http.PostForm sends a url.Values as an
application/x-www-form-urlencoded body. The keys and values are
encoded in the same way as query parameters.
package main
import (
"fmt"
"io"
"log"
"net/http"
"net/url"
)
func main() {
data := url.Values{
"name": {"John Doe"},
"occupation": {"gardener"},
}
resp, err := http.PostForm("http://127.0.0.1:8080/post", data)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.Status)
fmt.Print(string(body))
}
The server parses the body with ParseForm and returns the values.
Form data is limited to simple text fields; files require a multipart body.
$ go run main.go
200 OK
{"name":["John Doe"],"occupation":["gardener"]}
POST request with JSON data
A JSON body is marshalled to bytes and sent with http.Post. The
second argument becomes the Content-Type header, and
bytes.NewReader wraps the data in an io.Reader.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
)
func main() {
payload := map[string]any{
"name": "John Doe",
"age": 30,
"active": true,
}
data, err := json.Marshal(payload)
if err != nil {
log.Fatal(err)
}
resp, err := http.Post("http://127.0.0.1:8080/post", "application/json",
bytes.NewReader(data))
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.Status)
fmt.Print(string(body))
}
The server decodes the JSON into a map[string]any and encodes it
again. Object keys are sorted by encoding/json, so the echoed
object has the same key order as the original.
$ go run main.go
200 OK
{"active":true,"age":30,"name":"John Doe"}
Handling error statuses
A 404 is a valid HTTP response, so http.Get returns it with a nil
error. The status has to be checked explicitly, and the body usually contains
the server's explanation.
package main
import (
"fmt"
"io"
"log"
"net/http"
)
func main() {
resp, err := http.Get("http://127.0.0.1:8080/missing")
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.Status, resp.StatusCode)
if resp.StatusCode != http.StatusOK {
fmt.Printf("request failed: %s\n", body)
}
}
resp.Status is the text 404 Not Found and
resp.StatusCode is the number 404. Comparing the number against
http.StatusOK is the usual way to treat any non-success response as
an error.
$ go run main.go 404 Not Found 404 request failed: not found
Client timeouts
The zero value of http.Client has no timeout, so a request to an
unresponsive server can hang indefinitely. Setting Timeout covers
the whole exchange, including reading the body.
package main
import (
"fmt"
"net/http"
"time"
)
func main() {
client := &http.Client{Timeout: 500 * time.Millisecond}
resp, err := client.Get("http://127.0.0.1:8080/slow")
if err != nil {
fmt.Println("request failed:", err)
return
}
defer resp.Body.Close()
}
The server sleeps for two seconds, so the 500 ms deadline expires first. The
error is a *url.Error whose message mentions the client timeout,
and the program prints it instead of exiting with a log entry.
$ go run main.go request failed: Get "http://127.0.0.1:8080/slow": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
Concurrent requests
Each request blocks until the response arrives, so several requests are sent in
parallel with goroutines. A sync.WaitGroup waits for all of them,
and a mutex protects the shared result slice.
package main
import (
"fmt"
"io"
"log"
"net/http"
"slices"
"sync"
)
func main() {
var wg sync.WaitGroup
var mu sync.Mutex
var results []string
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
u := fmt.Sprintf("http://127.0.0.1:8080/get?id=%d", id)
resp, err := http.Get(u)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
mu.Lock()
results = append(results, fmt.Sprintf("id=%d status=%s", id, resp.Status))
mu.Unlock()
}(i)
}
wg.Wait()
slices.Sort(results)
for _, r := range results {
fmt.Println(r)
}
}
The loop variable is passed as an argument, so each goroutine sees its own
id. The results are appended in completion order, which varies, so
they are sorted before printing.
$ go run main.go id=1 status=200 OK id=2 status=200 OK id=3 status=200 OK id=4 status=200 OK id=5 status=200 OK
Source
Go net/http package - reference
Go net/url package - reference
In this article we have created GET and POST requests in Go, set headers, handled error statuses, limited the request time, and sent requests concurrently.
Author
List all Go tutorials.