Shortening a URL in Go is one POST with a JSON body. Marshal the destination, send it to the shortener's links endpoint with net/http, pass your API key as a Bearer token, and decode short_url from the response. The standard library does all of it, and the only dependency worth adding later is errgroup for bulk work.
Search results for this are dominated by "build your own shortener in Go" projects, which is a different exercise: those store links, this one calls a service that already does. If you want the storage side, how to build a URL shortener covers the design decisions. If you want a short link in the next five minutes, keep reading.
This is the Go entry in a series that also covers Python, JavaScript, and PHP. The endpoint shape, auth model, and free-tier limits assumed here are documented in the free URL shortener API overview.
The Fastest Way: One POST With net/http
Two structs, one request, one decode:
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
type linkRequest struct {
DestinationURL string `json:"destination_url"`
}
type linkResponse struct {
ID string `json:"id"`
ShortURL string `json:"short_url"`
}
func main() {
body, err := json.Marshal(linkRequest{
DestinationURL: "https://example.com/spring-sale?utm_source=newsletter",
})
if err != nil {
panic(err)
}
req, err := http.NewRequest(http.MethodPost, "https://api.elido.app/v1/links", bytes.NewReader(body))
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("ELIDO_API_KEY"))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
panic("shorten failed: " + resp.Status)
}
var link linkResponse
if err := json.NewDecoder(resp.Body).Decode(&link); err != nil {
panic(err)
}
fmt.Println(link.ShortURL) // https://s.elido.me/ab12cd
}
go run main.go with ELIDO_API_KEY exported and you have a short link. The panics are fine in a fifteen-line program and wrong everywhere else; the next section turns this into a function that returns errors.
The client := &http.Client{Timeout: ...} line is the one to keep. http.DefaultClient has no timeout, so http.Post against an unresponsive host blocks that goroutine until the connection dies on its own, which on a bad network can be minutes.
Give Every Request a Deadline and Share One Client
Production shape: a package-level client, a context on every request, and errors instead of panics.
var client = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 64,
MaxIdleConnsPerHost: 16, // default is 2, too low for a concurrent batch
IdleConnTimeout: 90 * time.Second,
},
}
const endpoint = "https://api.elido.app/v1/links"
func shorten(ctx context.Context, destination string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
body, err := json.Marshal(linkRequest{DestinationURL: destination})
if err != nil {
return "", fmt.Errorf("marshal: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("new request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("ELIDO_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("post %s: %w", endpoint, err)
}
defer func() {
io.Copy(io.Discard, resp.Body) // drain so the connection can be reused
resp.Body.Close()
}()
if resp.StatusCode >= 400 {
return "", fmt.Errorf("shorten: %s", resp.Status)
}
var link linkResponse
if err := json.NewDecoder(resp.Body).Decode(&link); err != nil {
return "", fmt.Errorf("decode: %w", err)
}
return link.ShortURL, nil
}
Three details earn their place. Reusing one http.Client keeps the connection pool alive across calls; constructing a client per request throws away every keep-alive. Draining the body before closing it is what actually lets the connection return to the pool, and it is the usual reason a batch job dials far more sockets than it should. And context on the request means a caller who gives up - a cancelled CLI, a request whose user disconnected - stops the HTTP call instead of leaving it running.
MaxIdleConnsPerHost deserves its comment. The default is 2, which is invisible in a script and expensive once eight goroutines are hitting the same host.
Retry on 429 and 5xx Without Creating Duplicates
There is a failure mode that a plain retry loop makes worse. The POST reaches the API, the link is created, and the response is lost on the way back. Your code sees a timeout, retries, and now two short links point at one destination. An Idempotency-Key closes it: same key, same logical request, and the API returns the original link rather than minting another.
Derive the key from the destination and it stays stable across retries and across re-runs of the same batch.
var (
errRateLimited = errors.New("rate limited")
errServer = errors.New("server error")
)
func shortenWithRetry(ctx context.Context, destination string, attempts int) (string, error) {
key := fmt.Sprintf("%x", sha256.Sum256([]byte(destination)))
var lastErr error
for attempt := 0; attempt < attempts; attempt++ {
short, retryAfter, err := postLink(ctx, destination, key)
if err == nil {
return short, nil
}
var wait time.Duration
switch {
case errors.Is(err, errRateLimited):
wait = retryAfter // from the Retry-After header
case errors.Is(err, errServer):
wait = time.Duration(1<<attempt) * time.Second // 1s, 2s, 4s
default:
return "", err // 401, 403, 422: retrying will not help
}
lastErr = err
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(wait):
}
}
return "", fmt.Errorf("shorten %q: %w", destination, lastErr)
}
Note the select instead of time.Sleep. Sleeping ignores cancellation, so a job told to shut down still sits there for four seconds per URL. Waiting on ctx.Done() and time.After at once means the backoff is interruptible, which is the difference between a clean shutdown and a SIGKILL. The rate limits and idempotency deep-dive covers the header semantics and why blind retries turn one outage into two.
Want to run this against a live endpoint? Create a key on the free plan, export ELIDO_API_KEY, and every snippet here compiles and runs as written.
Shorten in Bulk With errgroup and SetLimit
The naive concurrent version launches one goroutine per URL. With 5,000 URLs that is 5,000 simultaneous requests, and the API answers most of them with 429. errgroup plus SetLimit gives you a fan-out with a ceiling:
func shortenAll(ctx context.Context, urls []string) (map[string]string, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // at most 8 requests in flight, whatever len(urls) is
var mu sync.Mutex
out := make(map[string]string, len(urls))
for _, u := range urls {
g.Go(func() error {
short, err := shortenWithRetry(ctx, u, 3)
if err != nil {
return fmt.Errorf("%s: %w", u, err)
}
mu.Lock()
defer mu.Unlock()
out[u] = short
return nil
})
}
return out, g.Wait()
}
On Go 1.22 and newer the loop variable is per-iteration, so the old u := u line inside the loop is gone. The mutex is still required - a map written from several goroutines without one is a data race, and go test -race will say so.
One behaviour to know before you ship it: errgroup.WithContext cancels the shared context as soon as any goroutine returns an error, so the first hard failure stops the rest of the batch. That is what you want for a build step that must be all-or-nothing. For an importer that should shorten everything it can and report the failures at the end, collect errors into a slice, return nil from each goroutine, and keep the group purely as a concurrency limiter.
Typed Client or Raw net/http
For one endpoint, the code above is the whole integration and a dependency buys nothing. The calculus flips when you start listing links with pagination, filtering by tag, reading click totals, and handling half a dozen response shapes: that is when generated models and a client that already knows the retry rules save more than they cost. The API and SDKs page has the current list, and the SDK quickstart shows the typed version of this same call.
Either way the habits are identical: key from the environment, a deadline on every request, the status code checked before the body, a stable idempotency key on anything that can run twice, and a ceiling on concurrency. Once links are being created from a service rather than a laptop, webhooks for link events beat polling for finding out what happened to them, and what the platform exposes to developers covers the rest of the surface.
Read the Cornerstone Series
This sits in the engineering cluster. Start with the free URL shortener API guide for the endpoint shape and auth, then the rate limits and idempotency piece for load behaviour. The live reference is the API docs, and URL shorteners for developers covers what to check in an API before you build on it.
Related on the Blog
- How to shorten a URL in JavaScript with fetch and Node
- How to shorten a URL in Python with the requests library
- How to shorten a URL in PHP with curl, Guzzle, or WordPress
- URL shortener API rate limits and idempotency
- How to shorten a URL in Ruby with Net::HTTP and Faraday
- How to shorten a URL in Java with the built-in HttpClient
Поширені запитання
How do I shorten a URL in Go?
Marshal the long URL into a JSON body, POST it to a shortener's links endpoint with net/http, set your API key as a Bearer header, and decode short_url out of the response. The standard library covers all of it, so the whole function is about twenty lines with no third-party dependency.
Do I need a library to shorten URLs in Go?
No. net/http and encoding/json are enough for a single POST, and the only external package worth adding is golang.org/x/sync/errgroup for bounded concurrency in bulk jobs. A generated SDK pays off when you use many endpoints and want typed models and pagination rather than one call.
How do I set a timeout on a Go HTTP request?
Set Timeout on your own http.Client and build requests with http.NewRequestWithContext plus context.WithTimeout. http.DefaultClient has no timeout at all, so a hung server will block a goroutine forever. The client timeout covers the whole exchange; the context also lets a caller cancel early.
How do I shorten many URLs concurrently in Go?
Use errgroup with SetLimit so only a fixed number of requests are in flight, instead of launching one goroutine per URL and getting rate limited. Guard the results map with a mutex, and send a stable Idempotency-Key per URL so retries never create duplicate links.
Why is my Go HTTP client not reusing connections?
Usually because the response body is never fully read before it is closed, so the connection cannot go back to the idle pool. Drain it with io.Copy(io.Discard, resp.Body) before Close. The other common cause is the transport default of two idle connections per host, which is low for a concurrent batch job.
Why does my Go request to a URL shortener return 401?
The Authorization header is missing, misspelled, or holding an empty environment variable. Print os.Getenv before the request to confirm the key loaded, and check the header reads 'Bearer ' with the trailing space before the key. A 403 instead means the key is valid but lacks the scope for that endpoint.
Спробуйте Elido
Вставте URL - отримайте коротке посилання
Без реєстрації. Посилання живе 30 днів. Зареєструйтесь, щоб зберегти назавжди.
Безкоштовно, без реєстрації · 2 на день