ZetCode

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.

server.go
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.

main.go
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.

main.go
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.

main.go
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.

main.go
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.

main.go
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.

main.go
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.

main.go
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.

main.go
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.

main.go
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

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Go tutorials.