ZetCode

Go download image

last modified October 30, 2026

In this article we show how to download an image file in Go. A download is an HTTP GET request whose response body is copied to a file or decoded in memory. The examples use the net/http, io and image packages from the standard library.

All examples were compiled and run with Go 1.27.0. The image server runs on localhost, so the output does not depend on a public site.

The demo site

The examples download PNG files from a static file server on port 8099. Any static server works; the simplest one is the http.server module of Python, started in a directory that contains the images.

The files are small PNG images of 120 by 80 pixels. The server reports the size and the MIME type of each file, which the client can check before writing anything to disk.

$ ls
icon.png  logo.png  photo.png
$ python3 -m http.server 8099
Serving HTTP on 0.0.0.0 port 8099 ...

Downloading a file

http.Get sends the request and returns a response. The body is an io.Reader, and io.Copy streams it into the file without holding the whole image in memory.

main.go
package main

import (
    "fmt"
    "io"
    "net/http"
    "os"
)

func main() {

    url := "http://localhost:8099/logo.png"

    res, err := http.Get(url)
    if err != nil {
        fmt.Println(err)
        return
    }
    defer res.Body.Close()

    fmt.Println(res.StatusCode, res.Header.Get("Content-Type"), res.ContentLength)

    f, err := os.Create("logo.png")
    if err != nil {
        fmt.Println(err)
        return
    }
    defer f.Close()

    n, err := io.Copy(f, res.Body)
    if err != nil {
        fmt.Println(err)
        return
    }

    fmt.Println("bytes:", n)
}

The response reports status 200, the MIME type image/png and a content length of 219 bytes. io.Copy returns the number of bytes written, which matches the content length.

$ go run main.go
200 image/png 219
bytes: 219
$ file logo.png
logo.png: PNG image data, 120 x 80, 8-bit/color RGB, non-interlaced

Failed responses

http.Get returns an error only when the request cannot be completed. A 404 response is a successful HTTP exchange, so the error is nil and the body contains an error page. The status code must be checked explicitly.

main.go
package main

import (
    "fmt"
    "net/http"
)

func check(url string) {

    res, err := http.Get(url)
    if err != nil {
        fmt.Println(err)
        return
    }
    defer res.Body.Close()

    fmt.Printf("%-40s err=%v status=%q\n", url, err, res.Status)

    if res.StatusCode != http.StatusOK {
        fmt.Println("unexpected status:", res.Status)
        return
    }

    fmt.Println("ok")
}

func main() {

    check("http://localhost:8099/logo.png")
    check("http://localhost:8099/missing.png")
}

The missing file produces 404 File not found, the reason phrase used by the Python server. Without the guard, the error page would be saved under the name of the image.

$ go run main.go
http://localhost:8099/logo.png           err=<nil> status="200 OK"
ok
http://localhost:8099/missing.png        err=<nil> status="404 File not found"
unexpected status: 404 File not found

Choosing the file name

The last element of the URL path is a reasonable default name. url.Parse separates the path from the query string, and path.Base returns the final element.

main.go
package main

import (
    "fmt"
    "net/url"
    "path"
)

func filename(raw string) (string, error) {

    u, err := url.Parse(raw)
    if err != nil {
        return "", err
    }

    name := path.Base(u.Path)
    if name == "." || name == "/" || name == "" {
        return "", fmt.Errorf("no file name in %q", raw)
    }

    return name, nil
}

func main() {

    for _, raw := range []string{
        "http://localhost:8099/logo.png?size=large",
        "http://localhost:8099/images/photo.png",
        "http://localhost:8099/",
    } {
        name, err := filename(raw)
        fmt.Printf("%-46s %q %v\n", raw, name, err)
    }
}

The query string is dropped, so ?size=large does not become part of the file name. A URL that ends with a slash has no file name and is rejected. A name taken from a remote server should also be cleaned with filepath.Base before it is joined to a local directory, so that a path such as ../../etc/passwd cannot escape.

$ go run main.go
http://localhost:8099/logo.png?size=large      "logo.png" <nil>
http://localhost:8099/images/photo.png         "photo.png" <nil>
http://localhost:8099/                         "" no file name in "http://localhost:8099/"

Timeouts

http.Get uses the default client, which has no timeout. A production download should use an http.Client with an explicit timeout, so a stalled server cannot block the program forever.

main.go
package main

import (
    "fmt"
    "net/http"
    "time"
)

func main() {

    client := &http.Client{Timeout: time.Nanosecond}

    res, err := client.Get("http://localhost:8099/logo.png")
    if err != nil {
        fmt.Println(err)
        return
    }
    defer res.Body.Close()

    fmt.Println(res.Status)
}

The timeout is deliberately tiny to force the failure. Real code uses a few seconds, for example 10 * time.Second. The error wraps context.DeadlineExceeded and can be tested with errors.Is.

$ go run main.go
Get "http://localhost:8099/logo.png": context deadline exceeded (Client.Timeout exceeded while awaiting headers)

Limiting the response size

A server can send an arbitrarily large body. Reading one byte more than the allowed maximum shows whether the limit was exceeded, and io.LimitReader stops the transfer at that point.

main.go
package main

import (
    "fmt"
    "io"
    "net/http"
)

func download(url string, limit int64) ([]byte, error) {

    res, err := http.Get(url)
    if err != nil {
        return nil, err
    }
    defer res.Body.Close()

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

    data, err := io.ReadAll(io.LimitReader(res.Body, limit+1))
    if err != nil {
        return nil, err
    }

    if int64(len(data)) > limit {
        return nil, fmt.Errorf("response is larger than %d bytes", limit)
    }

    return data, nil
}

func main() {

    data, err := download("http://localhost:8099/logo.png", 1000)
    fmt.Println(len(data), err)

    _, err = download("http://localhost:8099/logo.png", 100)
    fmt.Println(err)
}

The first call reads the whole 219-byte image. The second one hits the limit and is rejected, even though only the extra byte was read. This pattern is useful when the image is decoded in memory.

$ go run main.go
219 <nil>
response is larger than 100 bytes

Decoding the image in memory

Sometimes the image is needed as an image.Image rather than as a file. image.Decode reads the format from the content, not from the file extension, and returns the format name and the dimensions.

main.go
package main

import (
    "bytes"
    "fmt"
    "image"
    _ "image/png"
    "io"
    "net/http"
)

func main() {

    res, err := http.Get("http://localhost:8099/logo.png")
    if err != nil {
        fmt.Println(err)
        return
    }
    defer res.Body.Close()

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

    img, format, err := image.Decode(bytes.NewReader(data))
    if err != nil {
        fmt.Println(err)
        return
    }

    fmt.Println("format:", format)
    fmt.Println("bounds:", img.Bounds())
    fmt.Println("size:  ", len(data), "bytes")
}

The blank import registers the PNG decoder. Without it, image.Decode fails with image: unknown format; the same error appears when an error page is decoded instead of an image.

$ go run main.go
format: png
bounds: (0,0)-(120,80)
size:   219 bytes

Downloading several images

Each download can run in its own goroutine. A sync.WaitGroup waits for all of them, and a slice indexed by the position of the URL keeps the output in a fixed order.

main.go
package main

import (
    "fmt"
    "io"
    "net/http"
    "os"
    "path"
    "sync"
)

func download(url string) (int64, error) {

    res, err := http.Get(url)
    if err != nil {
        return 0, err
    }
    defer res.Body.Close()

    if res.StatusCode != http.StatusOK {
        return 0, fmt.Errorf("%s: %s", url, res.Status)
    }

    f, err := os.Create(path.Base(res.Request.URL.Path))
    if err != nil {
        return 0, err
    }
    defer f.Close()

    return io.Copy(f, res.Body)
}

func main() {

    urls := []string{
        "http://localhost:8099/logo.png",
        "http://localhost:8099/icon.png",
        "http://localhost:8099/photo.png",
    }

    var wg sync.WaitGroup
    results := make([]string, len(urls))

    for i, url := range urls {
        wg.Add(1)
        go func() {
            defer wg.Done()
            n, err := download(url)
            if err != nil {
                results[i] = fmt.Sprintf("%-10s error: %v", path.Base(url), err)
                return
            }
            results[i] = fmt.Sprintf("%-10s %d bytes", path.Base(url), n)
        }()
    }

    wg.Wait()

    for _, r := range results {
        fmt.Println(r)
    }
}

The closure reads i and url directly. Since Go 1.22 each iteration has its own variables, so the goroutines see the values of their own iteration. Before Go 1.22 the loop variables were shared and the arguments had to be passed to the function.

$ go run main.go
logo.png   219 bytes
icon.png   219 bytes
photo.png  219 bytes

Serving an image from Go

The other side of the transfer is a small HTTP server. http.ServeFile sends the file and sets the content type, the content length and the Accept-Ranges header for partial requests.

main.go
package main

import (
    "log"
    "net/http"
)

func main() {

    http.HandleFunc("/image", func(w http.ResponseWriter, r *http.Request) {
        http.ServeFile(w, r, "logo.png")
    })

    log.Println("listening on http://localhost:8090")
    log.Fatal(http.ListenAndServe(":8090", nil))
}

The server needs logo.png in its working directory. A HEAD request shows the headers without transferring the body. The client from the first section works unchanged with the new URL.

$ go run main.go &
2026/10/30 20:03:42 listening on http://localhost:8090
$ curl -I http://localhost:8090/image
HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 219
Content-Type: image/png
$ sed -i 's|localhost:8099/logo.png|localhost:8090/image|' main.go
$ go run main.go
200 image/png 219
bytes: 219

Downloading with Req

The third-party req client wraps the same steps into one call. SetOutputFile writes the body directly to the named file.

main.go
package main

import (
    "fmt"
    "os"
    "time"

    "github.com/imroc/req/v3"
)

func main() {

    client := req.C().SetTimeout(10 * time.Second)

    res, err := client.R().SetOutputFile("logo.png").Get("http://localhost:8099/logo.png")
    if err != nil {
        fmt.Println(err)
        return
    }

    info, err := os.Stat("logo.png")
    if err != nil {
        fmt.Println(err)
        return
    }

    fmt.Println(res.Status, info.Size())
}

The example uses req v3.61.0. The response status and the size of the written file confirm the download; error handling is still required, and the file is created by the library.

$ go run main.go
200 OK 219

Source

Go net/http package - reference
Go image package - reference
Req HTTP client - Github page

In this article we have downloaded images in Go. We streamed a response into a file, checked the status code, derived the file name from the URL, set a client timeout, limited the response size, decoded the image in memory, downloaded several files concurrently, served an image from a Go server, and used the Req client for a shorter version of the same task.

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.