Go Projects for Beginners: A Practical Learning Path
Build these small Go projects in order to practice CLI programs, collections, modules, JSON, HTTP, testing, and reliable error handling.
The best first Go project is a small command-line program. Start with a program you can finish in one sitting, then add one new idea at a time: functions and collections, modules and errors, JSON, HTTP, and finally tests and security checks. This order keeps dependencies and scope under control while giving you working software at every step.
Go’s official learning path supports this progression. The getting-started tutorials cover installation, the go command, packages, and modules. A Tour of Go is interactive and includes exercises, while Go by Example provides annotated example programs.
1. Choose a project with a small, testable scope
Use these criteria when choosing between ideas:
| Project | Interface | Core concepts | Dependencies | Scope risk |
|---|---|---|---|---|
| Hello world | CLI | source files, go run |
Standard library | Very low |
| Word counter | CLI and file | functions, slices, maps, errors | Standard library | Low |
| Unit converter | CLI | parsing, validation, tests | Standard library | Low |
| Expense tracker | CLI and local file | structs, JSON, persistence | Standard library | Medium |
| Reusable module | Library plus caller | packages, modules, errors | One local module | Medium |
| REST service | HTTP | handlers, JSON, status codes | Standard library or Gin | Medium to high |
For every project, define “done” before coding: working behavior, input validation, automated tests for core logic, a README with run commands, and one small extension. Stop when those conditions are met.
2. Project one: Hello World and command-line basics
Install Go, a text editor, and a terminal as described in the official setup guide. Create a directory and initialize a module:
mkdir hello-go
cd hello-go
go mod init example.com/hello
touch main.go
Put this in main.go:
package main
import "fmt"
func main() {
fmt.Println("Hello, Go")
}
Run and build it:
go run .
go build -o hello
./hello
Practice the edit-run cycle by adding a name argument:
package main
import (
"fmt"
"os"
)
func main() {
name := "世界"
if len(os.Args) > 1 {
name = os.Args[1]
}
fmt.Printf("Hello, %s!\n", name)
}
Definition of done: the program runs with and without an argument, the README shows both commands, and go build succeeds.
3. Project two: a word counter
A word counter exercises file handling, functions, slices, maps, and error handling without a framework. Save this as wordcount/main.go:
package main
import (
"bufio"
"fmt"
"os"
"sort"
"strings"
)
func countWords(path string) (map[string]int, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
counts := make(map[string]int)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
for _, word := range strings.Fields(strings.ToLower(scanner.Text())) {
word = strings.Trim(word, ".,!?;:()[]{}\"'")
if word != "" {
counts[word]++
}
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
return counts, nil
}
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: wordcount FILE")
os.Exit(2)
}
counts, err := countWords(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, "read file:", err)
os.Exit(1)
}
words := make([]string, 0, len(counts))
for word := range counts {
words = append(words, word)
}
sort.Strings(words)
for _, word := range words {
fmt.Printf("%s\t%d\n", word, counts[word])
}
}
go mod init example.com/wordcount
go run . sample.txt
Useful extensions are a -top N flag, Unicode-aware tokenization, and an option to read standard input. Keep each extension separate so you can test it.
4. Project three: a unit converter or expense tracker
A unit converter is the smaller choice; an expense tracker adds persistence and therefore more failure cases.
Unit converter
package main
import (
"flag"
"fmt"
"os"
)
func main() {
celsius := flag.Float64("c", 0, "temperature in Celsius")
flag.Parse()
if flag.NFlag() == 0 {
fmt.Fprintln(os.Stderr, "usage: convert -c 20")
os.Exit(2)
}
fmt.Printf("%.2f C = %.2f F\n", *celsius, *celsius*9/5+32)
}
Add Kelvin conversion, reject impossible values, and place conversion formulas in functions so they can be tested without command-line arguments.
Expense tracker
Represent an expense with a struct containing an amount, category, date, and note. Store a slice of expenses as JSON. Validate that amounts are non-negative, dates use one documented format, and malformed files produce an actionable error. A bounded first version supports add, list, and total; defer authentication, a database, and a web UI.
5. Project four: a reusable module and caller
The official module tutorial and calling-a-module tutorial show how to split a library from an application. Create two directories:
mkdir -p greetings/hello
cd greetings
go mod init example.com/greetings
package greetings
import "fmt"
func Hello(name string) (string, error) {
if name == "" {
return "", fmt.Errorf("name is required")
}
return fmt.Sprintf("Hello, %s", name), nil
}
In a separate caller module, import the library and handle its error:
package main
import (
"fmt"
"log"
"example.com/greetings"
)
func main() {
message, err := greetings.Hello("Gopher")
if err != nil {
log.Fatal(err)
}
fmt.Println(message)
}
When the module is local, add a replace directive in the caller’s go.mod while developing. Remove it when the library is available from its real module path. This project teaches package boundaries, exported names, slices or maps passed between functions, and explicit error handling.
6. Project five: a JSON utility or local data service
The official tutorial catalog includes working with JSON. A useful beginner utility converts a JSON file into a summary report. Define a struct with JSON tags, decode with encoding/json.Decoder, validate required fields, and encode output with json.Encoder.
type Expense struct {
Amount float64 `json:"amount"`
Category string `json:"category"`
}
func loadExpenses(r io.Reader) ([]Expense, error) {
var expenses []Expense
if err := json.NewDecoder(r).Decode(&expenses); err != nil {
return nil, fmt.Errorf("decode expenses: %w", err)
}
for i, e := range expenses {
if e.Amount < 0 || e.Category == "" {
return nil, fmt.Errorf("expense %d is invalid", i)
}
}
return expenses, nil
}
Use an io.Reader instead of opening a file inside the function. That makes the logic easy to test with an in-memory string and keeps file errors in the command layer.
7. Project six: a small REST API
Only add HTTP after you are comfortable with packages, structs, JSON, and errors. The official catalog links to Developing a RESTful web service with Go and Gin. For a first service, endpoints such as GET /health, GET /expenses, and POST /expenses are enough.
package main
import (
"encoding/json"
"log"
"net/http"
)
func health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func main() {
http.HandleFunc("/health", health)
log.Println("listening on http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
go run .
curl -i http://localhost:8080/health
Before adding a framework, implement method checks, request-size limits, JSON decoding, status codes, and graceful shutdown with a server timeout. A framework is useful when routing and middleware become repetitive; it does not replace input validation or tests.
8. Or skip the browser setup
If your Go project needs screenshots for documentation, visual regression checks, or a URL preview, ScreenshotNeo provides a website screenshot API and MCP server. One GET request returns PNG, JPEG, WebP, or PDF. It accepts cookie banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed; response headers identify the page verdict and whether the request was billed.
See the ScreenshotNeo API documentation for all options.
cURL
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
Node.js
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
ScreenshotNeo also supports full-page capture, CSS element capture, dark mode, device presets, custom viewports, retina scale, PDFs, custom CSS and JavaScript, clicks, waits, blocked resources, headers, cookies, user agents, authorization, timezone, geolocation, transparent backgrounds, resizing, caching, signed links, asynchronous jobs, webhooks, bulk capture, usage reporting, and an OpenAPI specification. Its MCP server includes take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients.
Free accounts include 1,000 screenshots per month with no card. Paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
9. Add tests, fuzzing, and dependency checks
Start with table-driven unit tests for pure functions:
func TestCelsiusToFahrenheit(t *testing.T) {
cases := []struct{ c, want float64 }{{0, 32}, {100, 212}}
for _, tc := range cases {
got := tc.c*9/5 + 32
if got != tc.want {
t.Fatalf("got %v, want %v", got, tc.want)
}
}
}
go test ./...
go test -race ./...
As input combinations grow, use Go’s fuzzing support to search for crashes and invariant violations. For dependencies, run the official vulnerability checker, govulncheck, before sharing the project. Keep tests focused on behavior: valid input, empty input, malformed input, boundary values, and file or network errors.
10. Troubleshooting common beginner errors
| Error | Cause | Fix |
|---|---|---|
go: command not found |
Go is not installed or not on PATH. |
Install Go from the official site and restart the terminal. |
go.mod file not found |
The command ran outside a module. | Run go mod init example.com/project at the project root. |
imported and not used |
Go requires every import to be used. | Delete the import or use it; run gofmt. |
undefined: Name |
A spelling, package, or capitalization mistake. | Check the identifier and whether it is exported across packages. |
| JSON decode fails | Input shape or field types do not match the struct. | Inspect the payload, add JSON tags, and return the decode error with context. |
| HTTP handler hangs | A body was not closed or a handler is waiting on an unbounded operation. | Close response bodies, set timeouts, and avoid blocking work in request handlers. |
| Tests pass locally but fail elsewhere | Tests depend on working directory, time, or map iteration order. | Use temporary directories, injected clocks, and sorted output. |
11. Performance, reliability, and cost notes
- Keep the first version in memory or use a small local file. A database adds schema, migration, and connection concerns.
- Stream large files with
bufio.Scannerorio.Readerinstead of loading everything into memory. Increase a scanner buffer when lines can be large. - For HTTP clients and servers, set deadlines and limit request bodies. Return errors instead of silently dropping failed writes.
- Measure before optimizing.
go test -bench .and profiles can show whether parsing, allocation, or I/O is the bottleneck. - Local Go projects cost nothing beyond your machine. Hosted APIs, databases, and screenshot services introduce usage costs; keep credentials in environment variables and set explicit limits.
- For screenshot capture, cache stable URLs with a chosen TTL and use asynchronous jobs for slow pages. Check
X-Page-VerdictandX-Billedwhen accounting for usage.
12. A project checklist
- Write a one-paragraph scope and a definition of done.
- Initialize a module and keep the directory layout simple.
- Separate parsing, business logic, and I/O into functions.
- Return errors with context and validate all external input.
- Add table-driven tests for normal, empty, malformed, and boundary cases.
- Run
gofmt,go test ./..., andgo vet ./.... - Document installation, usage examples, limitations, and one planned extension.
- Only then add a framework, database, concurrency, or deployment configuration.
13. FAQ
Should my first Go project use Gin?
No. Build a CLI with the standard library first. Add Gin after you understand handlers, JSON, status codes, and errors.
How large should a beginner project be?
Small enough to finish in a few sessions. A narrow tool with tests teaches more than an unfinished clone of a large product.
Is concurrency required?
No. Learn goroutines and channels after the sequential version works and you can describe the shared state and cancellation behavior.
Which official resource should I start with?
Use the interactive Tour of Go for language practice, then follow the official tutorials while building your project. Go by Example is useful for short reference programs.
What should I build after a REST API?
Add persistence, authentication, observability, and deployment one at a time, or build a multi-module workspace using the official tutorial catalog.


