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.
| Form | Where it works | The type comes from | Use 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.
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
rawdeclared withvarwhilehostanderrare declared with:=inside theif? - What does
parseHostreturn alongside the error, and why does no caller need to care about it? - Delete the
if err != nilbranch 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.