Skip to content
gohacks.dev
Basics

Go Exercises That Teach the Language Fast and for Free

Free places to practice Go, plus ten graded exercises with hints and four worked solutions you can run, from palindromes to a Fibonacci iterator.

9 min read
Summarize in AI
Flat illustration of a rising bar chart and a gauge.

Reading about Go gets you perhaps a third of the way. The rest comes from writing small programs, having the compiler reject some of them and seeing the test output change from red to green. This page does two things. It lists the free places where good exercises already live, with an honest note about what each is for. Then it adds ten of my own, graded from easy to harder, with four worked solutions I ran on Go 1.25.6 so you have something real to compare against. It sits in the Go basics path, a few steps after the first program and before the larger projects.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

Where to practice

The Tour of Go. The Tour of Go runs in the browser, needs no installation, and takes a few hours. It is the right first stop because it is maintained by the Go team and its exercises, such as a Stringer, a Reader and a Fibonacci closure, are small and checkable. Its limit is that it stops before testing, modules and real projects.

The official tutorials. The tutorials on go.dev walk through getting started, creating a module, building a web service with Gin, generics and fuzzing. They run on your own machine, which matters: you meet go mod init, go test and the editor for the first time there.

Go by Example. Go by Example is a reference of short, annotated, runnable programs, one per feature. It is better used as a lookup than a course: when an exercise needs bufio.Scanner or sort.Slice, read the one page.

Exercism. The Go track on Exercism gives you problems as a local directory with a test file. You make the tests pass, submit, and can ask for mentor feedback on idiomatic style. The workflow matches what you will do at work, and the concept exercises introduce one idea at a time.

The standard library's examples. Every package on pkg.go.dev has Example functions that are compiled and run as tests. Reading, retyping and changing those is one of the best ways to learn a package, and it trains you to read documentation.

A method that works

Whichever source you use, the same loop gets results:

  1. Write the test first, even for a toy. A table of inputs and expected outputs forces you to think about edge cases before you code, and it gives you an instant verdict.
  2. Make it pass the simplest way, without hunting for the clever form.
  3. Compare with a reference solution, and write down one thing that differs. It is often a standard library function you did not know about.
  4. Redo it a week later from a blank file. The second attempt is where the knowledge sets.

Ten exercises, graded

These are deliberately small. Do them in order, writing a test table for each.

Level one: basics

  1. Palindrome. Report whether a string reads the same backwards, ignoring case and punctuation. Hint: convert to []rune, and keep only letters and digits.
  2. Word count. Count words in a string and return a map. Hint: strings.Fields.
  3. Anagram. Decide whether two strings are anagrams. Hint: compare sorted runes, or count letters in an array of 26 ints.

Level two: data structures

  1. Balanced brackets. Report whether "{[()]}" is balanced and "([)]" is not. Hint: a slice used as a stack.
  2. Roman numerals. Convert an integer from 1 to 3999 to a Roman numeral. Hint: a table of value and symbol pairs, from largest to smallest, and strings.Builder.
  3. Group by length. Given a slice of words, return a map from length to the words of that length, in input order. Hint: remember that a missing key in a map[int][]string is a nil slice, and append works on it.
  4. Matrix transpose. Transpose a rectangular [][]int. Hint: allocate the rows of the result yourself.

Level three: language features

  1. Fibonacci iterator. Write a function that yields Fibonacci numbers lazily, using iter.Seq, and take the first ten. Hint: range over func.
  2. Shape interface. Define a Shape interface with Area and Perimeter, implement it for a rectangle and a circle, and sort a slice of shapes by area. Hint: interfaces and slices.SortFunc.
  3. Retry with closure. Write Retry(n int, f func() error) error that calls f up to n times and returns the last error. Hint: closures, and make the test's f fail twice and then succeed.

Four worked solutions

Each solution is one way to do it. Yours may differ, and a different shape that passes the same tests is fine.

Palindrome

main.goGo
package main

import (
	"fmt"
	"unicode"
)

func isPalindrome(s string) bool {
	var rs []rune
	for _, r := range s {
		if unicode.IsLetter(r) || unicode.IsDigit(r) {
			rs = append(rs, unicode.ToLower(r))
		}
	}
	for i, j := 0, len(rs)-1; i < j; i, j = i+1, j-1 {
		if rs[i] != rs[j] {
			return false
		}
	}
	return true
}

func main() {
	for _, s := range []string{"A man, a plan, a canal: Panama", "Go gopher", "Été"} {
		fmt.Printf("%-32q %v\n", s, isPalindrome(s))
	}
}
Text
"A man, a plan, a canal: Panama" true
"Go gopher"                      false
"Été"                            true

The detail that separates a good answer from a working one is []rune. Indexing the string directly would compare bytes, and the third example, where É and é are two bytes each, would give the wrong answer for any non-ASCII letter. The two-index loop that moves from both ends toward the middle is a common Go shape, and the parallel assignment i, j = i+1, j-1 is why it needs no temporary.

Balanced brackets

main.goGo
package main

import "fmt"

func balanced(s string) bool {
	pairs := map[rune]rune{')': '(', ']': '[', '}': '{'}
	var stack []rune
	for _, r := range s {
		switch r {
		case '(', '[', '{':
			stack = append(stack, r)
		case ')', ']', '}':
			if len(stack) == 0 || stack[len(stack)-1] != pairs[r] {
				return false
			}
			stack = stack[:len(stack)-1]
		}
	}
	return len(stack) == 0
}

func main() {
	for _, s := range []string{"{[()]}", "([)]", "((", "", "a(b)c"} {
		fmt.Printf("%-8q %v\n", s, balanced(s))
	}
}
Text
"{[()]}" true
"([)]"   false
"(("     false
""       true
"a(b)c"  true

A slice is a stack in Go: append pushes, and stack[:len(stack)-1] pops. The loop ignores characters that are not brackets, so a(b)c is balanced, and the final len(stack) == 0 catches the opening brackets that never closed.

Roman numerals

main.goGo
package main

import (
	"fmt"
	"strings"
)

var table = []struct {
	value  int
	symbol string
}{
	{1000, "M"}, {900, "CM"}, {500, "D"}, {400, "CD"},
	{100, "C"}, {90, "XC"}, {50, "L"}, {40, "XL"},
	{10, "X"}, {9, "IX"}, {5, "V"}, {4, "IV"}, {1, "I"},
}

func roman(n int) string {
	var b strings.Builder
	for _, e := range table {
		for n >= e.value {
			b.WriteString(e.symbol)
			n -= e.value
		}
	}
	return b.String()
}

func main() {
	for _, n := range []int{4, 9, 14, 1994, 2026, 3999} {
		fmt.Println(n, roman(n))
	}
}
Text
4 IV
9 IX
14 XIV
1994 MCMXCIV
2026 MMXXVI
3999 MMMCMXCIX

Putting the subtractive pairs (CM, CD, XC) in the table, as ordinary rows, removes every special case. This is the usual trick with rule-based conversions: make the exceptions data. strings.Builder avoids the quadratic cost of repeated string concatenation, which would not matter for 3999 but is a habit worth having.

Fibonacci with iter.Seq

main.goGo
package main

import (
	"fmt"
	"iter"
)

func fib() iter.Seq[int] {
	return func(yield func(int) bool) {
		a, b := 0, 1
		for {
			if !yield(a) {
				return
			}
			a, b = b, a+b
		}
	}
}

func main() {
	n := 0
	for v := range fib() {
		if n == 10 {
			break
		}
		fmt.Print(v, " ")
		n++
	}
	fmt.Println()
}
Text
0 1 1 2 3 5 8 13 21 34

The sequence is infinite, and it works because the consumer pulls values and can stop at any time: break makes yield return false, and the function returns, so nothing leaks. That is the whole contract of iter.Seq, available since Go 1.23 (range over func). The earlier way to write this used a goroutine and a channel, which needs a way to cancel it and leaks if the consumer forgets. The iterator form has no goroutine at all.

Retry, with its test

The last exercise is best shown as a test, because the interesting part is how you check a function whose behavior depends on a callback:

retry.goGo
package main

// Retry calls f up to n times and returns nil on the first success, or the
// error from the final attempt.
func Retry(n int, f func() error) error {
	var err error
	for range n {
		if err = f(); err == nil {
			return nil
		}
	}
	return err
}
retry_test.goGo
package main

import (
	"errors"
	"testing"
)

func TestRetry(t *testing.T) {
	boom := errors.New("boom")

	t.Run("succeeds on the third try", func(t *testing.T) {
		calls := 0
		err := Retry(5, func() error {
			calls++
			if calls < 3 {
				return boom
			}
			return nil
		})
		if err != nil || calls != 3 {
			t.Errorf("err = %v, calls = %d, want nil and 3", err, calls)
		}
	})

	t.Run("gives up and returns the last error", func(t *testing.T) {
		calls := 0
		err := Retry(2, func() error { calls++; return boom })
		if !errors.Is(err, boom) || calls != 2 {
			t.Errorf("err = %v, calls = %d, want boom and 2", err, calls)
		}
	})
}
Text
=== RUN   TestRetry
=== RUN   TestRetry/succeeds_on_the_third_try
=== RUN   TestRetry/gives_up_and_returns_the_last_error
--- PASS: TestRetry (0.00s)
    --- PASS: TestRetry/succeeds_on_the_third_try (0.00s)
    --- PASS: TestRetry/gives_up_and_returns_the_last_error (0.00s)
PASS
ok  	example.test/lab	1.267s

The closure captures calls, which is how the test counts attempts and scripts the failures without any mocking tool. That is the closure idea from exercise ten in its most common real use. Two refinements are worth trying once this passes: stop early when the error is not worth retrying, using errors.Is against a list, and wait between attempts with a growing delay, which brings in time and context and leads to retry with backoff.

Using the compiler and vet as a teacher

Half of what a beginner learns from exercises comes from error messages, so read them closely instead of fixing by guesswork. The compiler tells you the line and the type involved, and most messages name both sides of a mismatch. Run go vet ./... after every exercise: it flags a Printf with the wrong verb, a copied lock and a self-assignment, each of which is a real mistake that compiles. If you want a second opinion on style, gofmt -d shows exactly what it would change, and the diff is a free lesson in the conventions. When a test fails, resist the urge to edit the test. Print the actual value with %#v in the failure message, and compare it character by character with what you expected. Most failures are an off-by-one, an unhandled empty input or a byte-versus-rune confusion, and the printed value usually shows which.

Keep a notes file as you go, with one line per surprise: "a nil map can be read but not written", "range over a string gives runes". Reread it before starting a project. The list is short, personal and more useful than any cheat sheet you did not write yourself.

When to stop doing drills

After ten or fifteen exercises the returns drop. You can write a function that works, but you have not yet had to decide how a program is laid out, where errors are handled or how to test something that prints. Projects teach that. Pick one from small Go programs to build while you are learning, build the guessing game or the todo list, and then check your code against the common beginner mistakes. Come back to drills when you meet a new area, such as concurrency, where a few small problems in a row are the best introduction.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.