5 real projects to learn Go if you already know how to code
Practical projects to learn Go: CLI, REST API, worker, Kafka consumer and file converter. With links to step-by-step tutorials.

Syntax exercises don’t work. You do twenty loop problems, three on slices, one on interfaces, and the next day you remember nothing. Not because you’re a bad student, but because the brain doesn’t retain what has no context. And Go syntax outside a real problem has no context.
The way to truly learn Go is to build small things that solve real problems. Not a TODO app. Not a “Hello World with goroutines”. Projects that resemble what you’d do at work or in a service you’d deploy to production.
I’ve been writing step-by-step Go tutorials for months, each focused on a specific project. This article is the map. Here I explain which five projects to build, in what order, what you’ll learn from each one, and why that order matters.
Why projects > tutorials for learning Go
There’s a huge difference between reading documentation and solving a problem. When you read about goroutines, you understand the concept. When you have to parallelize ten HTTP requests and aggregate results with a timeout, you learn goroutines.
This applies to any language, but in Go it’s especially true for two reasons:
- Go is minimalist by design. It doesn’t have a hundred ways to do the same thing. That means you internalize the correct patterns quickly, but only if you use them in context. Reading them isn’t enough.
- Go’s tooling is part of the language.
go test,go build,go mod,go vet… These aren’t extras. They’re fundamental. And you only learn them by using them in a project with real structure.
The classic trap is trying to learn Go by reading Effective Go from start to finish, or completing a four-hour syntax course. You end up knowing that channels exist but without having written one that solves something. You know that defer exists but haven’t felt why it matters in an HTTP handler that opens and closes connections.
The five projects I propose here are designed to cover the spectrum of what a backend developer needs. Each one attacks a different domain and forces you to use a different combination of language tools.
Project 1: CLI for local automation
What you build: A command-line tool that automates a repetitive task. It could be renaming files, cleaning logs, generating reports, whatever you want. What matters is that it receives arguments, processes something and returns a result.
Why this first: Because it eliminates all distractions. No HTTP, no database, no Docker. Just you, the language and the operating system. It’s the perfect ground to lay the foundations.
What you’ll learn
- Basic structure of a Go project with
go mod init - How to parse arguments with
os.Argsor libraries likecobra - File handling: reading, writing, traversing directories
- Go’s error handling pattern (
if err != nil) - Compiling a binary and distributing it
Minimal example
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
)
func main() {
if len(os.Args) < 3 {
fmt.Println("usage: rename <directory> <prefix>")
os.Exit(1)
}
dir := os.Args[1]
prefix := os.Args[2]
entries, err := os.ReadDir(dir)
if err != nil {
fmt.Fprintf(os.Stderr, "error reading directory: %v\n", err)
os.Exit(1)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
oldPath := filepath.Join(dir, entry.Name())
newPath := filepath.Join(dir, prefix+"_"+entry.Name())
if err := os.Rename(oldPath, newPath); err != nil {
fmt.Fprintf(os.Stderr, "error renaming %s: %v\n", oldPath, err)
continue
}
fmt.Printf("%s -> %s\n", oldPath, strings.TrimPrefix(newPath, dir+"/"))
}
}This is a real CLI. It’s not sophisticated, but you already have arguments, error handling, filesystem operations and a working binary. From here you can add flags, validations, formatted output, tests.
Full tutorial: CLI in Go
Project 2: REST API with professional structure
What you build: A REST API with CRUD endpoints, database connection, data validation and a folder structure that scales. Not a toy API with an in-memory map: a real API with PostgreSQL, migrations and tests.
Why this second: Because after the CLI you already master the language foundation. Now it’s time to learn how Go handles HTTP, JSON, middleware and everything surrounding backend web development.
What you’ll learn
- The
net/httppackage and how a server works in Go - Routing with the standard mux or frameworks like Gin
- JSON serialization and deserialization with struct tags
- Connecting to PostgreSQL with
database/sqlorsqlx - Code organization: handlers, services, repositories
- Middleware for logging, authentication, CORS
- Testing endpoints with
httptest
Example: basic handler
func GetTaskHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var task Task
err := db.QueryRow(
"SELECT id, title, done FROM tasks WHERE id = $1", id,
).Scan(&task.ID, &task.Title, &task.Done)
if err == sql.ErrNoRows {
http.Error(w, "task not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(task)
}
}Notice what’s not there: no magic framework hiding the request and response. No annotations. No automatic dependency injection. In Go you see exactly what happens on each line. That’s uncomfortable at first and an enormous advantage when something fails in production.
If you want a more complete project with PostgreSQL and Docker included, I have a specific tutorial: Task API with Go, PostgreSQL and Docker.
API tutorial: REST API with Go
Project 3: Background processing worker
What you build: A service that picks up tasks from a queue (could be Redis, a PostgreSQL table or an in-memory channel) and processes them in the background. Resize images, send emails, generate PDFs, whatever fits your use case.
Why this third: Because here is where Go starts to shine. Goroutines and channels stop being theory and become the tool your worker uses to process N tasks in parallel without blowing up memory.
What you’ll learn
- Goroutines and channels in a real scenario
- The worker pool pattern
context.Contextfor cancellation and timeoutssync.WaitGroupfor waiting for workers to finish- Graceful shutdown with OS signals
- Structured logging
Example: basic worker pool
func startWorkers(ctx context.Context, jobs <-chan Job, results chan<- Result, numWorkers int) {
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for {
select {
case <-ctx.Done():
log.Printf("worker %d: shutting down", workerID)
return
case job, ok := <-jobs:
if !ok {
return
}
result := process(job)
results <- result
}
}
}(i)
}
go func() {
wg.Wait()
close(results)
}()
}This pattern is the bread and butter of production Go services. You’ll see it in queue processors, data pipelines, in any system that needs to do heavy work without blocking the main flow.
What matters here isn’t just the code. It’s understanding how context lets you propagate cancellations, how channels give you synchronization without explicit locks, and how graceful shutdown prevents your worker from dying mid-processing when Kubernetes sends a SIGTERM.
Full tutorial: worker in Go
Project 4: Kafka consumer for event processing
What you build: A service that subscribes to a Kafka topic, reads messages, deserializes them, processes them and manages offsets and errors. It’s the project closest to what you’d find in a real microservices architecture.
Why this fourth: Because if you’ve already built an API and a worker, you have the foundation to understand distributed systems. Kafka adds the complexity of partitions, consumer groups, offsets and rebalancing. And learning to manage all of that in Go gives you a skill directly applicable in production.
What you’ll learn
- How Kafka works at the consumer level (topics, partitions, offsets, consumer groups)
- Using libraries like
confluent-kafka-goorsegmentio/kafka-go - Message deserialization (JSON, Avro, Protobuf)
- Error handling and retries
- Idempotent processing
- Metrics and health checks
Example: basic consumer
func consume(ctx context.Context, reader *kafka.Reader) error {
for {
select {
case <-ctx.Done():
return reader.Close()
default:
msg, err := reader.ReadMessage(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
return nil
}
log.Printf("error reading message: %v", err)
continue
}
var event OrderCreated
if err := json.Unmarshal(msg.Value, &event); err != nil {
log.Printf("error unmarshalling message: %v", err)
continue
}
if err := processOrder(ctx, event); err != nil {
log.Printf("error processing order %s: %v", event.OrderID, err)
// Decide here: retry? send to DLQ? log and continue?
continue
}
log.Printf("processed order %s from partition %d offset %d",
event.OrderID, msg.Partition, msg.Offset)
}
}
}Kafka is one of those technologies you can use for years without fully understanding it. Building a consumer from scratch forces you to understand what happens when a consumer goes down and another takes its partition, what manual vs automatic offset commit means, and why idempotent processing isn’t optional.
Full tutorial: Kafka with Go
Project 5: CSV to JSON converter
What you build: A tool that reads CSV files (potentially large ones), parses them, transforms them and writes them as JSON. It sounds simple. It’s not when the CSV has millions of rows, strange encodings or fields that don’t match the expected schema.
Why this fifth: Because it touches a completely different domain: data processing. And because it forces you to think about streaming, memory usage and how Go handles I/O efficiently.
What you’ll learn
encoding/csvandencoding/jsonpackages- Streaming reads without loading everything into memory
io.Readerandio.Writerinterfaces- Buffered I/O with
bufio - Data validation and transformation
- Testing with example files
- Flags for configuring behavior (delimiter, encoding, output format)
Example: streaming CSV to JSON
func convertCSVtoJSON(input io.Reader, output io.Writer) error {
reader := csv.NewReader(input)
headers, err := reader.Read()
if err != nil {
return fmt.Errorf("reading headers: %w", err)
}
encoder := json.NewEncoder(output)
encoder.SetIndent("", " ")
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("reading record: %w", err)
}
row := make(map[string]string, len(headers))
for i, header := range headers {
if i < len(record) {
row[header] = record[i]
}
}
if err := encoder.Encode(row); err != nil {
return fmt.Errorf("encoding row: %w", err)
}
}
return nil
}What’s interesting about this project is that it works with Go’s I/O interfaces, which are one of the most elegant pieces of the language. io.Reader and io.Writer are everywhere: files, HTTP connections, buffers, compressors. Understanding how to compose them is fundamental.
Also, this project scales naturally. You start with a basic converter and can add: support for enormous files with concurrent processing, automatic type detection, output in multiple formats (JSON Lines, Parquet), schema validation.
Full tutorial: CSV to JSON in Go
Bonus: web scraper with concurrency
It’s not in the five main projects because scraping has its own complications (legal, infrastructure, stability), but as a learning project it’s excellent.
What you build: A scraper that visits a list of URLs, extracts structured data and stores it. The beauty is making it concurrent: launching N goroutines, limiting the request rate, handling errors per URL without the entire system going down.
What you’ll learn
- Advanced HTTP client: timeouts, retries, custom headers
- Controlled concurrency with semaphores (
chan struct{}) - Rate limiting with
time.Ticker - HTML parsing with libraries like
goquery - Resilience patterns: circuit breaker, exponential backoff
func scrape(ctx context.Context, urls []string, concurrency int) []Result {
results := make([]Result, 0, len(urls))
sem := make(chan struct{}, concurrency)
var mu sync.Mutex
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
sem <- struct{}{} // acquire semaphore
defer func() { <-sem }() // release semaphore
data, err := fetchAndParse(ctx, u)
mu.Lock()
defer mu.Unlock()
if err != nil {
results = append(results, Result{URL: u, Error: err})
return
}
results = append(results, Result{URL: u, Data: data})
}(url)
}
wg.Wait()
return results
}The semaphore with a buffered channel is a pattern you see constantly in Go. It’s simple, requires no external library, and gives you precise control over how many concurrent operations you want to allow.
Full tutorial: scraper in Go
How to present these projects in your portfolio
Building the projects is only half of it. The other half is that someone can look at your GitHub and understand what you did and why. This applies whether you’re looking for a job or want to document your learning.
Structure of each repository
Each project should have:
- Clear README: What it does, how to run it, what technologies it uses. Not a three-paragraph README: a ten-line one with concrete instructions.
- Makefile or Taskfile: Commands for build, test, lint, run. So anyone can clone and run it in under a minute.
- Real tests: Not fake tests that check that
1+1 == 2. Tests that validate behavior, edge cases, expected errors. - Docker (when applicable): A multi-stage
Dockerfilethat produces a clean image. Adocker-compose.ymlif the project needs databases or other services. - CI configured: A GitHub Actions workflow that runs tests and linting on every push. It’s one line in your repo that says “I take this seriously”.
What each project demonstrates
| Project | Demonstrates |
|---|---|
| CLI | Language mastery, error handling, testing |
| REST API | Web architecture, database, middleware, HTTP testing |
| Worker | Concurrency, production patterns, graceful shutdown |
| Kafka consumer | Distributed systems, event-driven architecture |
| CSV to JSON | Efficient I/O, streaming, interface composition |
| Scraper (bonus) | Advanced concurrency, resilience, rate limiting |
You don’t need all six. Three well-done projects already tell a coherent story. But if you do the five main ones, you have a portfolio that covers practically everything a team looks for in a Go developer.
Progression: in what order to build them
The order matters. Each project assumes you already master what you learned in the previous one.
Level 1: CLI
This is your first real contact with Go. Here you learn the language: types, control flow, error handling, packages, compilation. Without external distractions.
Estimated time: 1-2 days if you already program in another language.
Level 2: REST API
A step up in complexity. Now you have HTTP, JSON, database. You learn how Go structures web applications and how they’re tested.
Estimated time: 3-5 days. More if you include PostgreSQL and Docker.
Level 3: Worker
The jump to real concurrency. Goroutines, channels, context, wait groups. This is where Go stops resembling any other language and you start thinking differently.
Estimated time: 2-3 days.
Level 4: Kafka consumer
Distributed systems. Operational complexity. This project isn’t just code: it’s understanding how the pieces work at the infrastructure level.
Estimated time: 3-5 days, including spinning up Kafka locally with Docker.
Level 5: CSV to JSON
You return to an apparently simple problem, but you attack it with everything you’ve learned. Streaming, interfaces, robust testing, edge case handling.
Estimated time: 1-2 days for the basic version. More if you add concurrency or support for enormous files.
The complete roadmap
CLI → REST API → Worker → Kafka Consumer → CSV/JSON Converter
│ │ │ │ │
│ │ │ │ └─ I/O, streaming, interfaces
│ │ │ └─ Distributed systems, event-driven
│ │ └─ Concurrency, goroutines, channels
│ └─ HTTP, JSON, DB, middleware, testing
└─ Foundations: types, errors, packages, compilationIf you want a broader map of the entire Go ecosystem, including intermediate and advanced concepts, I detail it in the general guide: Learning Go in 2026.
Common mistakes when learning Go with projects
Before you start, some mistakes I’ve seen (and made):
Starting too big
Don’t build a “complete microservice with Kafka, Redis, PostgreSQL and gRPC” as your first project. You’ll quit by day three. The CLI is your first project for a reason: it’s small, completable and gives you confidence.
Not writing tests from the start
In Go, testing is trivial. The testing package comes included, go test ./... runs everything, and the _test.go convention is so natural there’s no excuse. If you don’t test your learning projects, you’re leaving one of the best tools in the language on the table.
Copying without understanding
LLMs generate correct Go code in seconds. But if you copy without understanding why defer is used here, why the error is checked there, why the channel is buffered in this case and not in another, you’re not learning. Use AI as an accelerator, not a substitute.
Ignoring the tooling
go fmt, go vet, golangci-lint. Use them from the first project. Not later. Go’s tooling is part of the language’s culture and saves you hours of style debates that consume time in other languages.
Not reading other people’s code
After each project, look for similar implementations on GitHub. See how others do it. Idiomatic Go code has a recognizable style, and comparing your solution with others is the fastest way to improve.
Building is the only way to truly learn
Go isn’t learned by reading. It’s learned by building. And not just anything: projects that force you to solve real problems with the tools the language gives you.
The five projects I’ve proposed go from a CLI in Go to lay the foundations without distractions, through a REST API with Go that puts you straight into web development with a database, to a worker in Go where concurrency with goroutines and channels stops being theory. Kafka with Go takes you to distributed systems and event-driven architecture, and the CSV to JSON converter in Go forces you to work with efficient I/O, streaming and interfaces. And as a bonus, the scraper in Go for advanced concurrency and resilience patterns.
You don’t need to follow the order to the letter, but I recommend not skipping the first two. The CLI gives you the foundations and the API gives you the web context. From there, you can go to the worker, Kafka or the converter depending on what interests you most or what you need for your work. What you do need is to start. Open your terminal, run go mod init and write the first main.go. The best time was a week ago. The second best time is now.


