Skip to content
System nominalUTC --:--:--Articles 041
MODULE 01 Lesson 03 / 09 30 MIN

Getting started with Go

A First Program You Actually Understand

A short Go program read line by line, then broken on purpose, so the compiler's errors become messages you can act on rather than obstacles.

The first Go program most people write compiles on the second attempt. What stopped the first one was rarely the syntax. It was an import added while thinking and then abandoned, or a variable declared and never read. Go refuses both, and the messages are short enough to read as obstruction rather than information.

They are information. The program below is thirty lines, and every line is a decision you will make again in a service with a hundred files: the package clause, the imports, a function that returns two values, an if that declares its own variables, and an error travelling back to the caller as an ordinary value.

Read it once for the shape. Then run it, and break it on purpose.

package main

import (
	"fmt"
	"strconv"
	"strings"
)

// parsePort turns a configuration value into a TCP port number.
func parsePort(raw string) (int, error) {
	port, err := strconv.Atoi(strings.TrimSpace(raw))
	if err != nil {
		return 0, fmt.Errorf("port %q is not a number", raw)
	}
	if port < 1 || port > 65535 {
		return 0, fmt.Errorf("port %d is outside 1-65535", port)
	}
	return port, nil
}

func main() {
	var raw string = " 8080 "

	if port, err := parsePort(raw); err != nil {
		fmt.Println("config error:", err)
	} else {
		fmt.Println("listening on port", port)
	}
}
$ go run .
listening on port 8080

The package clause and the imports are checked declarations

The first non-comment line names the package this file belongs to. It says main because this is a program rather than a library, and because of that the toolchain will look for a function called main to start.

The import block declares dependencies at the file level. Each quoted string is an import path, and you refer to the package in code by its package name, which for these three happens to be the last element of the path: fmt, strconv, strings. That is a coincidence, not a rule. When you meet github.com/google/uuid you will find the package is named uuid, and when you meet one whose path ends in -go you will find the name has nothing to do with the path at all.

The blank line inside the import block separates standard library imports from everything else. gofmt sorts the paths inside each group alphabetically and leaves the groups alone, so the separation survives every save. It reads as a claim about where each dependency comes from, and it is worth keeping true.

An unused import is a compile error, and so is an unused variable

Delete the strings.TrimSpace call and leave the import in place, and the build stops:

$ go build ./...
./main.go:6:2: "strings" imported and not used

The reasoning behind that rule matters, because most languages let it slide. An import is a factual claim about what this file depends on. A claim that is not true makes the dependency list wrong, slows every build of the package, and usually signals a refactor that stopped halfway. The cost of the rule is that you delete a line while thinking and have to delete another one seconds later.

The same treatment applies to local variables. Add a variable inside a function and never read it, and the compiler names it:

./main.go:23:2: declared and not used: doubled

Two details keep this from becoming a nuisance. Package-level variables are exempt; the compiler will happily build a package with an unused exported variable, since it may be part of the package's public surface. And _ is a real identifier meaning "discard this", so x, _ := doThing() is how you say you know a second value came back and you do not want it.

Imports have the same escape hatch, and it is used for a real purpose. Writing _ "net/http/pprof" imports a package purely for the side effect of its initialisation, which registers profiling handlers on the default HTTP mux. The blank name says the package name is not needed here, and the compiler accepts the import as used.

:= declares and infers, and var declares and states

The program uses both forms on purpose.

FormWhere it worksThe type comes fromUse it when
port, err := strconv.Atoi(raw) Inside a function only Whatever the right-hand side produces The type is obvious from the call
var raw string = " 8080 " Inside or outside a function You say it, or the compiler infers it if you omit it You want a specific type stated, or the zero value
var count int Inside or outside a function The zero value for that type: 0 You want the zero value and not an inferred one

var raw string = " 8080 " states the type rather than letting the string literal decide it. The spaces around the digits are the reason the type is written out: raw is configuration text, not a number, and it stays text until parsePort decides what to do with it. Write this line as raw := " 8080 " and it still compiles, but the intent is less visible.

The zero values take a minute to learn: 0 for numbers, false for booleans, "" for strings, and nil for pointers, slices, maps, channels, functions and interfaces. A declared variable is never undefined. var count int gives 0, which is a real value you can compare against, and that is why Go has no separate "uninitialised" state to check for.

if can carry an initialiser, and its scope ends at the closing brace

This line does more than it looks like it does:

if port, err := parsePort(raw); err != nil {

The statement before the semicolon runs first, declaring port and err. Then the condition is evaluated. Both variables are in scope inside the if body and inside any else branch, and both are gone after the closing brace.

That scoping is the feature. Write the call on one line and the check on the next, and you have an err variable alive for the rest of the function, which someone will eventually read after the check has already returned. The initialiser form ties the value's lifetime to the decision about it, so there is no window in which a half-validated port exists.

Two consequences follow. You cannot use port after the block, which is why the program prints inside the else. And := is legal here because both names are new. The general rule is that := needs at least one name on the left that does not already exist in the same scope, so host, err := parseHost(raw) is fine even when err is already declared above it.

A function that can fail returns the error as a second value

The signature is the documentation:

func parsePort(raw string) (int, error)

Two results. The first is the value you asked for, and it is only meaningful when the second is nil. Inside the function, every failure path returns both:

if err != nil {
	return 0, fmt.Errorf("port %q is not a number", raw)
}

The 0 is a formality. Callers are not supposed to look at the first value when the error is non-nil, and returning the zero value makes that mistake obvious when they do. fmt.Errorf builds an error with a formatted message, and %q quotes the offending string so a value with trailing spaces is visible in the output. There is a %w verb that wraps an existing error so errors.Is can unwrap it later; that belongs to the errors module.

What Go does not have is try. Failure is in the type signature, so the compiler guarantees the caller has an error value in hand and does not guarantee they do anything with it. errcheck and similar tools exist because that gap is real. The convention that fills it is a four-word line, repeated everywhere:

if err != nil {
	return err
}

Read enough Go and you stop seeing that repetition as noise. It is the place where a function states what it can fail at, and it makes the failure paths greppable in a way that a thrown exception never is.

The compiler messages are the lesson

Each of these is the program above with one deliberate mistake, built in a directory where nothing else has changed.

An unused import:

./main.go:6:2: "strings" imported and not used

An unused local variable:

./main.go:23:2: declared and not used: doubled

The same := twice in one scope:

./main.go:23:6: no new variables on left side of :=

And a return that hands back one value where the signature promises two:

./main.go:18:9: not enough return values
	have (int)
	want (int, error)

Every message carries a file, a line and a column, and the last one prints what it found next to what it wanted. That pattern holds across the toolchain, which is the habit worth taking from this lesson: read the error as a sentence about two values that do not match, find which side is wrong, and fix that side. The compiler is not grading you.

Lab 25 minutes

Add a second validator so the program has two things that can fail.

Write parseHost(raw string) (string, error). It trims the input and returns an error when the result is empty. Call it from main using an if with an initialiser, and print the host when it succeeds.

Then answer these in writing, in a comment above main:

  • Why is raw declared with var while host and err are declared with := inside the if?
  • What does parseHost return alongside the error, and why does no caller need to care about it?
  • Delete the if err != nil branch and print the host unconditionally. Say what the compiler does, and why that answer is more useful than a program that runs and prints an empty string.

Finally, break your own file in three places and record the exact error for each: an unused import, an unused local variable, and a := where every name on the left already exists in the same scope.

Show the solution and the reasoning

The validator is short, and the shape matters more than the length:

func parseHost(raw string) (string, error) {
	host := strings.TrimSpace(raw)
	if host == "" {
		return "", errors.New("host is empty")
	}
	return host, nil
}

It needs errors added to the import block, and the two calls in main are then the same shape twice:

func main() {
	var raw string = " 8080 "

	if port, err := parsePort(raw); err != nil {
		fmt.Println("config error:", err)
	} else {
		fmt.Println("listening on port", port)
	}

	if host, err := parseHost("  api.internal  "); err != nil {
		fmt.Println("config error:", err)
	} else {
		fmt.Println("host:", host)
	}
}

raw uses var because there is no function call to infer a type from and because the explicit string documents that this is configuration text. The variables inside the two if statements use := because the calls already determine their types, and because their useful life ends at the closing brace. The second if declaring its own err is not a conflict with the first, since neither is in scope at the point the other exists.

parseHost returns the empty string with the error. That value is deliberately useless: a caller that ignores the error gets an empty host rather than a plausible one, which fails loudly at the next step instead of quietly connecting to the wrong place. Returning a partially valid value on failure is how a config bug turns into a production incident.

The third question has the answer people do not expect. Delete the error branch and keep the call, and the program stops compiling with:

./main.go:40:8: declared and not used: err

You cannot write host, err := parseHost(raw) and then ignore err, because err is a local variable and local variables must be read. The only way to discard it is to write _ in its place, which is a visible decision in the source rather than an omission. That is the whole argument for errors as values in one compile error: the compiler will not force you to handle the failure, but it will force you to admit you are not handling it.

The three deliberate breakages produce these, in this order, when you use the original program from the top of the lesson:

./main.go:6:2: "strings" imported and not used
./main.go:23:2: declared and not used: doubled
./main.go:23:6: no new variables on left side of :=

All three name a line and a column and nothing else. That is enough information once you know the three rules, which is why they are worth collecting deliberately at least once instead of meeting them one at a time under deadline.

Progress

Mark this lesson done and it stays marked in this browser.