Skip to content
gohacks.dev
Basics

Small Go Programs to Build While You Are Still Learning

Fifteen small Go projects for beginners, ordered by difficulty, with the packages each one teaches and three short starters you can run today.

8 min read
Summarize in AI
Flat illustration of an editor window full of code.

Tutorials teach syntax, and projects teach everything else: how to lay out a module, what to do when a file is missing, how to read arguments, how to test code that prints. The trouble is choosing a project at the right size. Too big and you stall in week two with a half-built web framework; too small and you learn nothing you did not already know from the tour. This list is fifteen programs I would hand to someone who has finished the basics path, ordered so that each one adds a single new idea to the last. Every one uses only the standard library, and every one fits in a few hundred lines.

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

Three of them have a short starter below that I ran on Go 1.25.6, so you can see the scale of the first step. The rest are described by what they teach, because the point is for you to write them.

Stage one: text and data in memory

These projects need nothing but fmt, strings, bufio, sort and maps, and they teach you to think in slices and maps rather than arrays and objects.

1. Word frequency counter. Read text, count how often each word appears, print the top ten. It teaches maps, bufio.Scanner with ScanWords, sorting a slice of structs and strings.ToLower. Stretch: ignore a list of stop words read from a file, and handle Unicode punctuation properly with unicode.IsLetter.

main.goGo
package main

import (
	"bufio"
	"fmt"
	"io"
	"slices"
	"strings"
	"unicode"
)

type entry struct {
	word  string
	count int
}

func top(r io.Reader, n int) []entry {
	counts := map[string]int{}
	sc := bufio.NewScanner(r)
	sc.Split(bufio.ScanWords)
	for sc.Scan() {
		w := strings.ToLower(strings.TrimFunc(sc.Text(), func(r rune) bool { return !unicode.IsLetter(r) }))
		if w != "" {
			counts[w]++
		}
	}
	var all []entry
	for w, c := range counts {
		all = append(all, entry{w, c})
	}
	slices.SortFunc(all, func(a, b entry) int {
		if a.count != b.count {
			return b.count - a.count
		}
		return strings.Compare(a.word, b.word)
	})
	return all[:min(n, len(all))]
}

func main() {
	text := "The cat and the hat. The CAT sat; the hat sat on a mat!"
	for _, e := range top(strings.NewReader(text), 3) {
		fmt.Println(e.count, e.word)
	}
}
Text
4 the
2 cat
2 hat

Notice that top takes an io.Reader and returns a slice. That one decision means it works on a file, on standard input or on a string in a test. Notice too the tie-break on the word: map iteration order is random (map iteration order), so without a second sort key the output would change between runs.

2. Unit converter. convert 12 km mi prints 7.46 mi. It teaches os.Args or the flag package (command line arguments), switch, strconv.ParseFloat and formatting floats with %.2f. Stretch: temperature, which is the first conversion that is not a multiplication.

3. Number guessing game. The classic, and the one that first teaches you to separate rules from input and output. The guessing game walkthrough builds it with tests.

4. Mini grep. Print the lines of a file that match a pattern, with -i and -n options and a proper exit code. It teaches bufio, regexp, flags and the convention that exit status 1 means "no match".

main.goGo
package main

import (
	"bufio"
	"fmt"
	"io"
	"regexp"
	"strings"
)

func grep(r io.Reader, re *regexp.Regexp, numbers bool) (matches int) {
	sc := bufio.NewScanner(r)
	for n := 1; sc.Scan(); n++ {
		if !re.MatchString(sc.Text()) {
			continue
		}
		matches++
		if numbers {
			fmt.Printf("%d:", n)
		}
		fmt.Println(sc.Text())
	}
	return matches
}

func main() {
	input := "alpha\nBeta\ngamma\nbeta again\n"
	re := regexp.MustCompile(`(?i)^beta`)
	n := grep(strings.NewReader(input), re, true)
	fmt.Println("matches:", n)
}
Text
2:Beta
4:beta again
matches: 2

The (?i) prefix turns on case-insensitive matching, so -i is a matter of building the pattern differently, not of changing the loop. Go's regexp uses RE2 syntax and guarantees linear time, which is why a user-supplied pattern cannot hang the program, a property the standard library documents in the regexp package overview.

Stage two: files, JSON and structure

Here the program starts to keep state between runs, and you meet errors that are the world's fault rather than yours.

5. Todo list. Add, list, complete and remove tasks, stored in a JSON file. It teaches structs with tags, encoding/json, os.ReadFile, handling a missing file and a corrupt one, and a safe save. The standard-library todo app is a full walkthrough.

6. CSV to JSON converter. Read a CSV with a header, write an array of objects. It teaches encoding/csv, encoding/json, and the first honest confrontation with untyped data: is "007" a number? Stretch: stream the output with json.Encoder so a large file does not need to fit in memory.

7. Password generator. Generate a password of a given length from chosen character classes. It teaches crypto/rand, which you must prefer to math/rand for anything secret, strings.Builder and testing a function whose output is random by checking properties instead of values.

main.goGo
package main

import (
	"crypto/rand"
	"fmt"
	"strings"
)

const (
	lower  = "abcdefghijklmnopqrstuvwxyz"
	upper  = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
	digits = "0123456789"
)

func password(n int, alphabet string) string {
	var b strings.Builder
	for b.Len() < n {
		var buf [1]byte
		if _, err := rand.Read(buf[:]); err != nil {
			panic(err)
		}
		// Reject values past the largest multiple of len(alphabet), so every
		// character is equally likely.
		if int(buf[0]) >= 256-256%len(alphabet) {
			continue
		}
		b.WriteByte(alphabet[int(buf[0])%len(alphabet)])
	}
	return b.String()
}

func main() {
	p := password(16, lower+upper+digits)
	fmt.Println(len(p), strings.Trim(p, lower+upper+digits) == "")
}
Text
16 true

The output is a length and a boolean because the password itself changes every run, which is the lesson: you test properties, not values. The rejection step avoids modulo bias. Taking a random byte modulo 62 would make the first characters of the alphabet slightly more likely than the rest, and a generator for secrets should not have that flaw.

8. File duplicate finder. Walk a directory, hash each file and report the ones with identical hashes. It teaches filepath.WalkDir, crypto/sha256, io.Copy into a hash, and maps from a hash to a slice of paths. Stretch: compare sizes first, so most files are never read. Hashing many files concurrently is the follow-up when you meet goroutines.

9. Key-value store with an append-only log. set, get and delete commands, each recorded as a line in a file and replayed on start. It teaches file appending, replay, bufio and the idea of a write-ahead log that every database uses.

Stage three: the network

10. URL shortener. An HTTP server that maps short codes to URLs. It teaches net/http, handlers, a mutex around a map (the mutex), redirects and httptest. It also teaches that two requests run at once, which is when the data race detector earns its keep (the race detector).

11. Link checker. Fetch a page, extract its links and report broken ones. The first version is sequential and slow; the second uses a bounded set of goroutines. It teaches net/http clients, timeouts, context and the difference between a status code and a network error. See fetching URLs in parallel when you reach the second version.

12. Log analyzer. Parse a web server's access log, count requests per status code and per minute, and print the slowest paths. It teaches regexp or strings.Fields, time.Parse (time formatting) and aggregation with maps of structs.

13. Chat server over TCP. Accept connections with net.Listen, and broadcast each line to the others. It is the classic goroutine and channel project: one goroutine per connection, one hub that owns the list of clients. A walkthrough is in building a chat server.

14. Job scheduler. Run commands at intervals with time.Ticker, stop cleanly on Ctrl-C, and log each run. It teaches os/signal, context cancellation and how to shut a program down without leaking goroutines (graceful shutdown).

15. Rate-limited API client. Call a public JSON API, respect a limit of N requests per second, retry on 429 and 503 with backoff. It teaches net/http, encoding/json decoding into structs, time and the retry logic every production client eventually needs (retry with backoff).

How to work through them

A few habits matter more than which project you pick.

Finish each one properly. Write the README with the command to run it, add tests for the logic, run gofmt and go vet, and tag a version. The last stretch is where a program becomes something you could hand to someone else, and the habits you form there stay with you.

Keep the logic away from the terminal. In all three starters above the interesting function takes a reader or a string and returns a value, and main is a few lines. That is not a style preference. It is what makes the program testable, and it is the structure of most Go services.

Read the standard library as you go. When a project introduces bufio or encoding/csv, read the package documentation and one or two of its examples before you write a line. The examples on pkg.go.dev are runnable, short and correct. Reading real Go is also a skill, and how to read Go code has a method for it.

Stop copying. When you feel stuck, resist pasting a whole solution. Search for the one function you need, read how it behaves and write the call yourself. You will remember what you typed far longer than what you pasted.

When you have two or three of these behind you and want drills instead of projects, Go exercises that teach the language lists small problems with answers to compare against, and common beginner mistakes is worth reading with your finished code open.

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.