Developers

How to generate QR codes in Go

By Sam Moreton · updated 30 June 2026

The usual advice for QR codes in Go is to go get github.com/skip2/go-qrcode and render locally. That is genuinely fine for a CLI or a one-off job. But when you want zero dependencies, identical output across every service, and SVG or PNG on demand from the same code path you already use in Python or Node, an HTTP call is the cleaner option. This guide shows how to generate QR codes in Go by calling the free OpenQR REST API with the standard net/http package — one request in, finished image out.

9 min read · Updated 30 June 2026

API vs a local library — be honest about the trade-off

Local libraries like skip2/go-qrcode are good tools. If you are writing a CLI, generating codes fully offline, or you specifically want no network dependency, go get one and move on — there is no shame in it. The case for an API is different: it is about consistency and reach, not raw QR generation.

  • Zero dependencies — nothing in your go.mod, nothing to pin, vendor or audit. The whole integration is net/http and encoding/json from the standard library.
  • Consistent server-side output — the same endpoint returns the same image whether it runs in an HTTP handler, a worker goroutine, a cron job or a Lambda. No per-environment rendering quirks.
  • SVG or PNG on demand — ask for vector when you need crisp print scaling, raster when a tool only accepts bitmaps. No second library for the other format.
  • One code path across languages — your Go service and your Python service hit the same URL with the same parameters, so the QR looks identical everywhere.

When a local library is the right call

If you need fully offline generation, have no outbound network, or are rendering millions of codes where a round-trip per code matters, use a local library — skip2/go-qrcode is solid and pure Go. The API shines when you value consistency, no dependency footprint, and shared output across services over avoiding a network hop.

Get a free API key

Every call needs a key. Go to openqr.uk/api, sign in with a magic link (enter your email, click the link — no password), then create a key in the dashboard. It is shown once and stored only as a SHA-256 hash on our side, so copy it somewhere safe. Keys look like oqr_… and travel as a bearer token.

Why the API is key-gated when the tool is free

The in-browser generator is free and needs no signup. The API is tied to an account so we can keep abuse off the network — that is the trade-off that lets the API itself stay free. Keep your key on the server; never ship it to a client or commit it to git.

Read the key from an environment variable

Never hard-code the key in your source. Export it into the environment (or load it from your deploy platform's secrets manager) and read it with os.Getenv. The examples below all assume OPENQR_KEY is set.

# Set it in your shell, .env, or your deploy platform's secrets manager.
export OPENQR_KEY="oqr_YOUR_API_KEY"

The /v1/qr image endpoint

There is one endpoint for rendering images: https://openqr.uk/v1/qr. It accepts GET (parameters in the query string) and POST (parameters in a JSON body), and returns the rendered image directly — the raw image/svg+xml or image/png bytes, not JSON wrapping a URL. Use GET for short data; use POST when your data is long or awkward to URL-encode.

ParameterTypeDefaultNotes
datastring (required)Text or URL to encode. Max 2000 characters.
formatsvg | pngsvgSVG is vector; PNG is raster.
sizeinteger51264–2048. Pixel size for PNG, viewBox for SVG.
margininteger40–16. The quiet zone in modules around the code.
darkhexForeground colour, e.g. 232E3A (no #). SVG only.
lighthexBackground colour, e.g. FFFFFF (no #). SVG only.
themestringA saved theme's id or name. Applies the theme's colours and margin.

Minimal example: save a PNG

The shortest useful program. A GET request with the data in the query string, the key read from the environment, and the response bytes written straight to disk. url.Values encodes the params for you, and defer resp.Body.Close() makes sure the connection is released.

package main

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

func main() {
	q := url.Values{}
	q.Set("data", "https://example.com")
	q.Set("format", "png")
	q.Set("size", "600")

	req, err := http.NewRequest("GET", "https://openqr.uk/v1/qr?"+q.Encode(), nil)
	if err != nil {
		panic(err)
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("OPENQR_KEY"))

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body) // error responses are JSON
		panic(fmt.Sprintf("OpenQR %d: %s", resp.StatusCode, body))
	}

	out, err := os.Create("qr.png")
	if err != nil {
		panic(err)
	}
	defer out.Close()

	if _, err := io.Copy(out, resp.Body); err != nil {
		panic(err)
	}
}

Always check the status before saving

On success you get image bytes; on failure you get JSON like { "error": "…" } with a 400 or 401 status. Check resp.StatusCode before you copy the body to a file, or you will write an error message into qr.png. And always defer resp.Body.Close() — Go does not do it for you, and a leaked body holds the connection open.

POST with a JSON body for long data

When your data is long — a vCard, a wifi string, a URL stuffed with query parameters — a query string gets unwieldy. Send a POST with a JSON body instead, marshalled with encoding/json. A map[string]any keeps the keys lower-case without struct tags. The parameters are identical; only the transport changes.

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
)

func main() {
	payload := map[string]any{
		"data":   "WIFI:T:WPA;S:My Network;P:super-secret-password;;",
		"format": "png",
		"size":   800,
		"margin": 2,
	}

	body, err := json.Marshal(payload)
	if err != nil {
		panic(err)
	}

	req, err := http.NewRequest("POST", "https://openqr.uk/v1/qr", bytes.NewReader(body))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("OPENQR_KEY"))
	req.Header.Set("Content-Type", "application/json")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		detail, _ := io.ReadAll(resp.Body)
		panic(fmt.Sprintf("OpenQR %d: %s", resp.StatusCode, detail))
	}

	out, err := os.Create("wifi.png")
	if err != nil {
		panic(err)
	}
	defer out.Close()

	io.Copy(out, resp.Body)
}

Render SVG instead of PNG

Ask for format=svg and the response is text — the raw <svg>…</svg> markup. SVG is resolution-independent, so it stays sharp at any print size, it is tiny over the wire, and you can inline it straight into HTML or a PDF with no second request. It is the better default for anything you control end to end. Note that the dark and light colour parameters apply to SVG output.

package main

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

func fetchSVG(data string) (string, error) {
	q := url.Values{}
	q.Set("data", data)
	q.Set("format", "svg")
	q.Set("size", "480")
	q.Set("dark", "232E3A")  // foreground hex, no '#'
	q.Set("light", "FFFFFF") // background hex, no '#'

	req, err := http.NewRequest("GET", "https://openqr.uk/v1/qr?"+q.Encode(), nil)
	if err != nil {
		return "", err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("OPENQR_KEY"))

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

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", err
	}
	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("OpenQR %d: %s", resp.StatusCode, body)
	}
	return string(body), nil // the raw <svg> markup
}

func main() {
	svg, err := fetchSVG("https://example.com")
	if err != nil {
		panic(err)
	}
	if err := os.WriteFile("qr.svg", []byte(svg), 0o644); err != nil {
		panic(err)
	}
}

Serving the bytes from an http.Handler

Saving to disk is only one option. In a web service you usually want to stream the image straight back to the caller. Here is an http.HandlerFunc that takes a data query parameter, fetches a PNG on demand, and pipes the bytes back with the right content type — no temp file, no library.

package main

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

func qrHandler(w http.ResponseWriter, r *http.Request) {
	data := r.URL.Query().Get("data")
	if data == "" {
		http.Error(w, "missing 'data'", http.StatusBadRequest)
		return
	}

	q := url.Values{}
	q.Set("data", data)
	q.Set("format", "png")
	q.Set("size", "512")

	req, _ := http.NewRequest("GET", "https://openqr.uk/v1/qr?"+q.Encode(), nil)
	req.Header.Set("Authorization", "Bearer "+os.Getenv("OPENQR_KEY"))

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		http.Error(w, "QR service unreachable", http.StatusBadGateway)
		return
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		http.Error(w, "QR service error", http.StatusBadGateway)
		return
	}

	w.Header().Set("Content-Type", "image/png")
	w.Header().Set("Cache-Control", "public, max-age=86400")
	io.Copy(w, resp.Body)
}

func main() {
	http.HandleFunc("/qr.png", qrHandler)
	http.ListenAndServe(":8080", nil)
}

Cache deterministic codes

The QR for a fixed data string never changes, so you only need to generate it once. Cache the bytes by their data string — in memory with a sync.Map, on disk, or behind your CDN with a Cache-Control header as above — and you will rarely touch the API after the first render. For per-record codes (a unique URL each), key the cache on the record id.

A reusable client with proper error handling

The endpoint returns image bytes on success and a JSON error body on failure, so the one mistake to avoid is treating the body as an image before checking the status. Wrap the call in a small helper that branches on resp.StatusCode, decodes the error JSON for a useful message, and returns the bytes on success. Reuse a single http.Client with a timeout rather than http.DefaultClient.

package openqr

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"time"
)

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

// MakeQR renders a QR code and returns the raw image bytes.
func MakeQR(data, format string, size int) ([]byte, error) {
	q := url.Values{}
	q.Set("data", data)
	q.Set("format", format)
	q.Set("size", strconv.Itoa(size))

	req, err := http.NewRequest("GET", "https://openqr.uk/v1/qr?"+q.Encode(), nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("OPENQR_KEY"))

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

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	if resp.StatusCode != http.StatusOK {
		// error responses are JSON: {"error": "..."}
		var e map[string]string
		if json.Unmarshal(body, &e) == nil && e["error"] != "" {
			return nil, fmt.Errorf("OpenQR %d: %s", resp.StatusCode, e["error"])
		}
		return nil, fmt.Errorf("OpenQR %d: %s", resp.StatusCode, body)
	}
	return body, nil
}
StatusMeaningFix
200Image renderedRead the body — PNG bytes or SVG markup.
400Bad requestdata is missing, over 2000 chars, or the JSON body is invalid.
401UnauthorizedMissing or wrong key — send Authorization: Bearer oqr_…

These are static codes

Everything above produces static QR codes: the data is encoded directly into the image, so the code never expires and needs no server to resolve. You cannot change where it points after printing. If you need an editable, trackable code, that is a dynamic code created via a different endpoint.

When you need to repoint a printed code or read scan analytics, reach for dynamic codes, which expose both over the same API from Go. For the wider API tour — every parameter, the OpenAPI spec, and other languages — see getting started with the QR code API. If you are generating codes in bulk, the bulk generation guide covers batching, and if you wire QR generation into an AI agent there is an MCP server too. The same patterns translate to Python and PHP if part of your stack is not Go.

Get your free API keySign in with a magic link, create a key, and call /v1/qr from Go in minutes — free, no card.
Both work. Use a local library (skip2/go-qrcode is solid and pure Go) when you want fully offline generation or have no outbound network. Use the API when you want zero dependency footprint, identical output across services and languages, and SVG or PNG on demand from one code path.

Related reading