Most Go tutorials start with variables. Then loops. Then functions. By the time

you've written ten small programs that do nothing, you still don't know what Go

is actually good for or why anyone would choose it.

This course works differently.

The program already exists. It runs. You will see its output.


The Program

weatherback is a Go HTTP server. You give it a city name. It calls two

external APIs — a geocoding service and a weather service — and returns current

conditions as JSON.

curl "http://localhost:8080/weather?city=Boston"
{
  "city": "Boston",
  "country": "United States",
  "temp_c": 12.3,
  "temp_f": 54.14,
  "windspeed_kmh": 18.5,
  "condition": "Partly cloudy",
  "day_or_night": "day"
}

Real data. A real city. Right now.

That is the destination.


Before You Begin

You need:

Go installed. Version 1.25 or later. Check with go version. If it's not

there, get it from go.dev/dl. The install is

straightforward on Linux, macOS, and Windows — unpack, set your PATH, done.

A terminal you're comfortable in. You don't need to be a Linux power user.

You need to know how to navigate directories and run commands. If you can cd

and run a program, you're ready.

curl. Already present on Linux and macOS. On Windows it's in PowerShell

5.1+. Verify with curl --version. This is how you'll talk to the server

throughout the course.

No IDE required. No prior Go experience. No package managers beyond what Go

ships with. If you have those three things, you're set.

Get the source and run it:

git clone https://github.com/bensantora-tech/weatherback
cd weatherback
go run main.go

Open a second terminal and hit it:

curl "http://localhost:8080/weather?city=Boston"

See it work. Then come back.


The Server

What does a Go program do when it starts?

Session 1 — Entry Point and Routing

Open main.go and find func main(). This is where Go begins. Every Go program

has exactly one. When you run go run main.go, Go compiles the file and calls

main(). Nothing runs before it.

func main() {
    http.HandleFunc("/", rootHandler)
    http.HandleFunc("/weather", weatherHandler)

    addr := "localhost:8080"
    fmt.Println("weatherback listening on", addr)
    fmt.Println("try: curl \"http://localhost:8080/weather?city=Boston\"")

    if err := http.ListenAndServe(addr, nil); err != nil {
        fmt.Println("server error:", err)
    }
}

http.HandleFunc registers a route: when a request arrives at this URL path,

call this function. The two lines above register two routes — / and /weather.

http.ListenAndServe opens the port and blocks. The program does not exit.

It waits for connections. This is why the terminal hangs when you run it — that

is correct behavior.

fmt.Println writes to the terminal. This is the server's side of the

conversation, visible only to whoever is running it.

import at the top of the file declares what the program uses. Everything

here is from Go's standard library — no external packages, nothing to install.

Session 2 — The Simplest Handler

Find rootHandler:

func rootHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "weatherback is running.")
    fmt.Fprintln(w, "try: GET /weather?city=Boston")
}

Every handler in Go has the same signature: two parameters, no return value.

w http.ResponseWriter is what you write your response into. Think of it as

the pipe back to the client. fmt.Fprintln(w, ...) writes a line of text into

that pipe.

**r *http.Request** is everything that came in with the request — the URL, the

method, the headers, the body. You read from r, you write to w.

Hit it:

curl http://localhost:8080/

That text you see in the terminal is what you wrote into w.

Session 3 — A Real Handler: Receiving Input

Find weatherHandler. The first thing it does is check the HTTP method:

if r.Method != http.MethodGet {
    http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
    return
}

HTTP methods are the verb of a request. GET means fetch something. POST means

send something. The weather endpoint only makes sense as a GET — you are asking

for data, not submitting it. If someone sends a POST, reject it immediately.

http.Error writes an error response — a plain-text body and a status code.

HTTP status codes are the machine-readable signal of what happened:

  • 200 OK — worked
  • 400 Bad Request — the client sent something wrong
  • 404 Not Found — the thing they asked for doesn't exist
  • 405 Method Not Allowed — wrong verb
  • 502 Bad Gateway — we tried to reach an upstream service and it failed

return exits the handler immediately. This is the Go pattern for early

exits: check a condition, handle the bad case, return. The happy path runs at

the bottom, not in an else block.

Next, the handler reads the city from the URL:

city := r.URL.Query().Get("city")
if city == "" {
    http.Error(w, "missing required query param: city", http.StatusBadRequest)
    return
}

Query parameters are the key=value pairs after the ? in a URL.

/weather?city=Bostoncity is the key, Boston is the value.

r.URL.Query().Get("city") extracts it. If it's missing, return a 400.

Exercise: Add a /ping handler that returns {"status":"ok"} as plain text.

Register it in main(). Test it with curl.


Data

How does Go understand the outside world?

Session 1 — The Shape of Data

External APIs return JSON — raw text. Go needs structure. The bridge between

them is the struct.

Find the data shapes at the top of main.go:

type Location struct {
    Name      string  `json:"name"`
    Latitude  float64 `json:"latitude"`
    Longitude float64 `json:"longitude"`
    Country   string  `json:"country"`
}

A struct is a named collection of fields. Each field has a name and a type.

string holds text. float64 holds a decimal number. int holds a whole number.

The backtick annotations — ` json:"name" ` — are struct tags. They are

instructions to the JSON package: when you decode JSON, map the field called

"name" in the JSON to the Name field in this struct. Without tags, Go would

look for a JSON field called "Name" (capital N) and find nothing.

Session 2 — Exported, Unexported, and Anonymous

Notice that some types start with a capital letter and some don't:

type Location struct { ... }        // exported — capital L
type geoResponse struct { ... }     // unexported — lowercase g
type WeatherReport struct { ... }   // exported — capital W
type weatherResponse struct { ... } // unexported — lowercase w

In Go, a capital first letter means exported — visible outside the package.

Lowercase means unexported — internal only.

Location and WeatherReport are exported because they are the shapes we

might want to share. geoResponse and weatherResponse are unexported because

they are implementation details — the raw shapes the external APIs return, which

we never expose directly.

Now find weatherResponse:

type weatherResponse struct {
    Current struct {
        Temperature   float64 `json:"temperature_2m"`
        Windspeed     float64 `json:"windspeed_10m"`
        Weathercode   int     `json:"weathercode"`
        IsDay         int     `json:"is_day"`
    } `json:"current"`
}

Current is an anonymous struct — a struct defined inline, with no separate

type name. The Open-Meteo API nests its current conditions under a "current"

key. This struct mirrors that shape exactly. Go's JSON decoder navigates the

nesting because the struct mirrors it.

And geoResponse:

type geoResponse struct {
    Results []Location `json:"results"`
}

[]Location is a slice — a variable-length list of Location values. The

geocoding API wraps its results in an array because a city name might match

multiple places. A slice is how Go holds an array of things whose size isn't

known at compile time.

Session 3 — Parsing and Producing JSON

Find the JSON parsing inside geocode:

var geo geoResponse
if err := json.Unmarshal(body, &geo); err != nil {
    return nil, fmt.Errorf("parsing geocode response: %w", err)
}

json.Unmarshal takes raw bytes and a pointer to a struct, and fills the

struct with whatever the JSON contains. The struct tags do the field mapping.

After this line, geo.Results holds the parsed locations.

&geo is the address of geo — a pointer. json.Unmarshal needs a pointer

because it must modify the struct, not a copy of it.

Now find where we write JSON back to the client, at the end of weatherHandler:

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)

enc := json.NewEncoder(w)
enc.SetIndent("", "  ")
enc.Encode(report)

w.Header().Set sets a response header before writing the body. Content-Type

tells the client what format to expect. Set headers before writing the body —

after the first write, headers are locked.

json.NewEncoder(w) creates an encoder that writes directly into w — the

response stream. enc.SetIndent makes the output human-readable with two-space

indentation. enc.Encode(report) serializes the struct to JSON.

Find where the WeatherReport is assembled in weatherHandler:

dayOrNight := "night"
if weather.Current.IsDay == 1 {
    dayOrNight = "day"
}

tempC := weather.Current.Temperature
tempF := tempC*9/5 + 32

report := WeatherReport{
    City:       location.Name,
    Country:    location.Country,
    TempC:      tempC,
    TempF:      tempF,
    Windspeed:  weather.Current.Windspeed,
    Condition:  describeWeatherCode(weather.Current.Weathercode),
    DayOrNight: dayOrNight,
}

This is data transformation — taking the raw shapes the APIs returned and

building one clean shape of our own design. The API gives us is_day as an

integer (0 or 1). We convert it to a human string. The API gives temperature in

Celsius only. We compute Fahrenheit. The caller should never see the raw API

shapes — only WeatherReport, shaped by us.

Exercise: The API also returns wind direction as a numeric degree (0–360).

Add a WindDirection field to WeatherReport. Write a function that converts

degrees to compass points (N, NE, E, SE, S, SW, W, NW). Add it to the assembly.


The Chain

How does Go talk to the outside world?

Session 1 — Making an Outbound HTTP Request

Find geocode:

func geocode(city string) (*Location, error) {
    apiURL := "https://geocoding-api.open-meteo.com/v1/search?name=" +
        url.QueryEscape(city) + "&count=1&language=en&format=json"

    resp, err := http.Get(apiURL)
    if err != nil {
        return nil, fmt.Errorf("geocoding request failed: %w", err)
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    ...
}

http.Get makes an outbound GET request and returns a response and an error.

Go always returns errors as values — there are no exceptions. The pattern is

always the same: call the function, check the error immediately, handle it or

return it.

return nil, fmt.Errorf(...) returns two values — Go functions can return

more than one. This function returns *Location and error. On failure, we

return nil (no location) and a descriptive error. On success, we return the

location and nil (no error).

fmt.Errorf("...: %w", err) wraps the original error with context. The %w

verb embeds the original error so it can be unwrapped later if needed. The string

explains where in the chain the failure happened.

defer resp.Body.Close() schedules resp.Body.Close() to run when the

surrounding function returns — no matter how it returns, success or failure.

HTTP responses hold an open network connection. If you don't close the body,

you leak that connection. defer ensures cleanup happens even if the function

exits early through an error return.

io.ReadAll(resp.Body) reads the entire response body as a byte slice. The

body is a stream — you read it once. After io.ReadAll, body holds all the

bytes. Then json.Unmarshal parses them.

Session 2 — URL Construction and Pointers

url.QueryEscape(city) percent-encodes the city name so it is safe to

embed in a URL. A city like "New York" contains a space. Spaces are not valid

in URLs. url.QueryEscape converts it to New+York. Without this, the URL

breaks for any city with a space or special character.

fmt.Sprintf builds a string from a format template and values. In

fetchWeather:

apiURL := fmt.Sprintf(
    "https://api.open-meteo.com/v1/forecast?latitude=%f&longitude=%f&current=...",
    lat, lon,
)

%f is the placeholder for a float64 value. fmt.Sprintf returns the completed

string. Use it whenever you are building a string from multiple variables.

Pointers — the return type of geocode is *Location, not Location.

The * means pointer — instead of returning a copy of the Location value, the

function returns the memory address where that value lives. For structs, this

is more efficient: you pass one small address rather than copying every field.

It also allows nil as a return value — a plain Location cannot be nil, but

*Location can. This is how Go functions signal "nothing to return" on the

error path.

Session 3 — The Switch and the Chain

Find describeWeatherCode:

func describeWeatherCode(code int) string {
    switch {
    case code == 0:
        return "Clear sky"
    case code <= 2:
        return "Partly cloudy"
    case code == 3:
        return "Overcast"
    ...
    default:
        return "Unknown"
    }
}

A switch without an expression is a clean alternative to a chain of

if/else if blocks. Each case is evaluated top to bottom. The first one that

is true runs, then the switch exits. default runs if nothing else matched.

The WMO weather codes are numeric ranges — this pattern maps them to strings

without a lookup table.

Now read weatherHandler from top to bottom as a complete sequence:

1. Check the method — reject non-GET immediately

2. Read the city from the query string — reject empty immediately

3. Call geocode(city) — city name → coordinates

4. Call fetchWeather(location.Latitude, location.Longitude) — coordinates → raw weather

5. Transform the raw data into a WeatherReport

6. Encode and write the report as JSON

This is the chain: each step produces output that feeds the next step.

Each step can fail independently. Each failure is handled at the point it

occurs, with a status code appropriate to what went wrong.

The result is a handler that is easy to read in sequence, fails clearly, and

returns exactly one shape — WeatherReport — regardless of what the upstream

APIs actually return.

Session 4 — The Module

go.mod:

module weatherback

go 1.25.0

Two lines. module weatherback names this module. go 1.25.0 declares the

minimum Go version. No dependencies listed because there are none — everything

weatherback uses is in Go's standard library.

This is deliberate. net/http, encoding/json, io, fmt, net/url — Go

ships all of these. A production-grade HTTP server, a JSON encoder and decoder,

a complete HTTP client: all in the standard library, nothing to install, nothing

to version-pin, nothing to break.


Final Exercise

Add a batch endpoint: GET /weather/batch?cities=Boston,London,Tokyo

It should call geocode and fetchWeather for each city in the list and return

a JSON array of WeatherReport objects.

Requirements:

  • Parse the cities query parameter as a comma-separated list
  • Loop over the cities, calling the existing functions for each
  • Collect results into a slice of WeatherReport
  • Return the slice as a JSON array
  • If one city fails, include an error message for that city but continue with the rest

This exercise uses: slices, loops, string splitting (strings.Split), JSON

arrays, and the existing functions unchanged.


What You've Covered

One program. Here's what you now understand:

Go fundamentals: entry point, functions, types, structs, slices, pointers,

error handling, defer, switch statements, exported vs unexported identifiers.

HTTP as a protocol: methods (GET, POST), status codes, query parameters,

request headers, response headers, Content-Type.

Working with JSON: struct tags, Unmarshal for parsing, Encoder for writing,

the relationship between Go types and JSON shapes.

Outbound HTTP: making requests, reading response bodies, URL encoding,

closing connections with defer.

Program design: the chain pattern, data transformation, keeping raw API

shapes internal and exposing only what you own.

Nothing here came from a syntax drill. Every concept earned its place because

the program needed it.


*weatherback source: github.com/bensantora-tech/weatherback*

*Written by Ben Santora — bensantora.com*