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.

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:
- 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.
- Make it pass the simplest way, without hunting for the clever form.
- Compare with a reference solution, and write down one thing that differs. It is often a standard library function you did not know about.
- 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
- Palindrome. Report whether a string reads the same backwards, ignoring case and punctuation. Hint: convert to
[]rune, and keep only letters and digits. - Word count. Count words in a string and return a map. Hint:
strings.Fields. - Anagram. Decide whether two strings are anagrams. Hint: compare sorted runes, or count letters in an array of 26 ints.
Level two: data structures
- Balanced brackets. Report whether
"{[()]}"is balanced and"([)]"is not. Hint: a slice used as a stack. - 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. - 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][]stringis a nil slice, andappendworks on it. - Matrix transpose. Transpose a rectangular
[][]int. Hint: allocate the rows of the result yourself.
Level three: language features
- Fibonacci iterator. Write a function that yields Fibonacci numbers lazily, using
iter.Seq, and take the first ten. Hint: range over func. - Shape interface. Define a
Shapeinterface withAreaandPerimeter, implement it for a rectangle and a circle, and sort a slice of shapes by area. Hint: interfaces andslices.SortFunc. - Retry with closure. Write
Retry(n int, f func() error) errorthat callsfup to n times and returns the last error. Hint: closures, and make the test'sffail 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
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))
}
}"A man, a plan, a canal: Panama" true
"Go gopher" false
"Été" trueThe 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
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))
}
}"{[()]}" true
"([)]" false
"((" false
"" true
"a(b)c" trueA 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
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))
}
}4 IV
9 IX
14 XIV
1994 MCMXCIV
2026 MMXXVI
3999 MMMCMXCIXPutting 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
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()
}0 1 1 2 3 5 8 13 21 34The 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:
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
}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)
}
})
}=== 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.267sThe 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.


