Golang Tutorial for Beginners: Go Features, Goroutines and a REST API with Gin

Go's key features, the fundamentals with working code, goroutines and channels, testing, and a small REST API with Gin. Every example was compiled and run.

Golang for Beginners cover: a main.go snippet that starts three goroutines with sync.WaitGroup.Go and waits for them, with measured results of 300 ms sequential, about 100 ms with goroutines, and 100% test coverage

Introduction

Go, often called Golang, is an open-source programming language created at Google. Robert Griesemer, Rob Pike and Ken Thompson started it in 2007, it went public in 2009 and Go 1.0 shipped in 2012.

They built it to fix real problems with large codebases at Google: slow builds, complex dependencies and code that was hard to read. The result is a small language that compiles fast, runs fast and makes concurrent code simple to write.

Today Go powers much of the cloud. Docker, Kubernetes, Terraform and Prometheus are all written in Go. It is a common choice for REST APIs, microservices, CLI tools and infrastructure software.

Key Features of Go

  • Simple, readable syntax. Go has 25 keywords. There is usually one obvious way to write something, so Go code from different teams looks alike.
  • Compiled to a single binary. Go compiles to native machine code. You ship one file with no runtime to install, which makes Docker images and deployments small.
  • Static typing. Every variable has a type that is checked at compile time, so many bugs are caught before the program runs. Type inference (x := 10) keeps code short.
  • Garbage collection. Go frees unused memory for you. Its garbage collector runs alongside your program to keep pauses short.
  • Goroutines. A goroutine is a function running concurrently with others. It is far cheaper than an operating system thread, so a program can run thousands of them.
  • Channels. Channels let goroutines send values to each other safely, without sharing variables.
  • Interfaces. An interface lists methods. Any type that has those methods satisfies the interface automatically; no implements keyword.
  • Explicit error handling. Functions return errors as normal values. There are no exceptions to forget to catch.
  • Built-in testing. go test runs tests, benchmarks and coverage with no extra framework.
  • Large standard library and cross-platform builds. HTTP servers, JSON, crypto and testing are built in. GOOS=windows go build builds a Windows binary from Linux or macOS.

Go Installation and Setup

Install Go

Download the installer for your system from go.dev/dl.

  • macOS and Windows: run the package or MSI installer.
  • Linux: remove any old version, extract the archive into /usr/local, and add Go to your PATH:
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.27.2.linux-amd64.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.profile
source ~/.profile

Verify the installation

go version
go version go1.27.2 linux/amd64

Create and run your first program

A module is a Go project with a go.mod file that records its name and dependencies.

mkdir hello && cd hello
go mod init example.com/hello

Create main.go:

package main

import "fmt"

func main() {
	fmt.Println("Hello, Go!")
}
go run .
Hello, Go!

package main and func main() mark the program's entry point. go run . compiles and runs it; go build creates a binary you can ship.

Go Fundamentals with Examples

Variables and constants

const maxRetries = 3

var name string = "Riyas" // explicit type
var age = 30              // type inferred: int
city := "Kochi"           // short form, only inside functions
var count int             // zero value: 0

fmt.Println(name, age, city, count, maxRetries)
Riyas 30 Kochi 0 3

Variables you declare without a value get a zero value: 0, "", false or nil. Go refuses to compile if you declare a local variable and never use it.

Data types

TypeExampleNotes
int, int6442Whole numbers
float6419.99Decimal numbers
string"Go"Immutable UTF-8 text
booltruetrue or false
rune'G'One Unicode character
byte'A'One byte (alias for uint8)

Go never converts types silently. You convert explicitly:

id, price := 42, 19.99
total := float64(id) * price
fmt.Printf("%.2f %T\n", total, total)

n, err := strconv.Atoi("123") // string to int can fail, so it returns an error
fmt.Println(n+1, err)
839.58 float64
124 <nil>

If-else and switch

if n, err := strconv.Atoi("42"); err != nil {
	fmt.Println("not a number")
} else if n > 40 {
	fmt.Println(n, "is big")
}

day := "sat"
switch day {
case "sat", "sun":
	fmt.Println("weekend")
default:
	fmt.Println("weekday")
}

An if can start with a short statement; n and err exist only inside the if. A switch case does not fall through to the next one, so there is no break to forget. A switch with no value replaces long if-else chains:

func grade(score int) string {
	switch {
	case score >= 90:
		return "A"
	case score >= 75:
		return "B"
	default:
		return "C"
	}
}

For loops

for is Go's only loop keyword. It covers every kind of loop:

for i := 0; i < 3; i++ { // classic loop
	fmt.Print(i, " ")
}

for i := range 3 { // same thing, Go 1.22+
	fmt.Print(i, " ")
}

n := 1
for n < 100 { // works like while
	n *= 2
}

for i, lang := range []string{"Go", "Python"} { // index and value
	fmt.Println(i, lang)
}
0 1 2
0 1 2
0 Go
1 Python

n ends at 128. Use for { ... } with break for an infinite loop.

Functions and multiple return values

func divide(a, b float64) (float64, error) {
	if b == 0 {
		return 0, errors.New("division by zero")
	}
	return a / b, nil
}

result, err := divide(10, 4)
fmt.Println(result, err) // 2.5 <nil>

_, err = divide(1, 0)
fmt.Println(err)         // division by zero

Returning a value and an error together is the standard Go pattern. Use _ to ignore a value you do not need.

Arrays, slices and maps

ports := [3]int{80, 443, 8080} // array: fixed length
fmt.Println(ports, len(ports))

langs := []string{"Go", "Python"} // slice: grows as needed
langs = append(langs, "Rust")
fmt.Println(langs, len(langs), langs[1:])

stock := map[string]int{"apple": 5, "banana": 0} // map: key-value pairs
stock["cherry"] = 12
if _, ok := stock["mango"]; !ok {
	fmt.Println("mango not found")
}
delete(stock, "banana")
[80 443 8080] 3
[Go Python Rust] 3 [Python Rust]
mango not found

You will use slices far more than arrays. Always use the value, ok form when a missing map key matters; reading a missing key returns the zero value, not an error.

Structs and methods

A struct groups related fields. A method is a function attached to a type.

type User struct {
	Name  string
	Email string
	Age   int
}

func (u User) Greeting() string { // value receiver: reads a copy
	return "Hi, " + u.Name
}

func (u *User) Birthday() { // pointer receiver: changes the original
	u.Age++
}

u := User{Name: "Asha", Email: "asha@example.com", Age: 29}
fmt.Println(u.Greeting())
u.Birthday()
fmt.Printf("%+v\n", u)
Hi, Asha
{Name:Asha Email:asha@example.com Age:30}

Names that start with a capital letter (User, Name) are exported: other packages can use them. Lowercase names are private to the package.

Pointers

A pointer holds the memory address of a value. &x gets the address; *p reads or changes the value at it.

func resetValue(n int)    { n = 0 }  // gets a copy
func resetPointer(n *int) { *n = 0 } // gets the address

x := 10
resetValue(x)
fmt.Println(x) // 10, the copy changed, not x
resetPointer(&x)
fmt.Println(x) // 0

Use pointers when a function must change its argument, or to avoid copying a large struct. There is no pointer arithmetic, so they are much safer than in C.

Interfaces

type Shape interface {
	Area() float64
}

type Rectangle struct{ Width, Height float64 }
type Circle struct{ Radius float64 }

func (r Rectangle) Area() float64 { return r.Width * r.Height }
func (c Circle) Area() float64    { return math.Pi * c.Radius * c.Radius }

func printArea(s Shape) {
	fmt.Printf("%T area: %.2f\n", s, s.Area())
}

for _, s := range []Shape{Rectangle{Width: 3, Height: 4}, Circle{Radius: 1}} {
	printArea(s)
}
main.Rectangle area: 12.00
main.Circle area: 3.14

Neither type mentions Shape. They satisfy it because they have an Area() method. Keep interfaces small; the standard library's io.Reader has one method.

Error handling

An error in Go is any value with an Error() string method. You check it right after each call.

var ErrNotFound = errors.New("user not found")

type ValidationError struct{ Field string }

func (e *ValidationError) Error() string { return e.Field + " is required" }

func findUser(id int) (User, error) {
	if id != 1 {
		return User{}, fmt.Errorf("find user %d: %w", id, ErrNotFound) // wrap with context
	}
	return User{Name: "Asha"}, nil
}

func validate(u User) error {
	if u.Email == "" {
		return &ValidationError{Field: "email"}
	}
	return nil
}

_, err := findUser(42)
fmt.Println(err)
fmt.Println(errors.Is(err, ErrNotFound)) // is ErrNotFound anywhere in the chain?

var vErr *ValidationError
if errors.As(validate(User{Name: "Ravi"}), &vErr) { // is it this error type?
	fmt.Println("invalid field:", vErr.Field)
}
find user 42: user not found
true
invalid field: email

%w wraps the original error so callers can still detect it with errors.Is and errors.As.

Advanced Go Features

Goroutines and channels

Start a goroutine with the go keyword, or with sync.WaitGroup.Go (Go 1.25+), which also waits for it:

func fetch(url string) string {
	time.Sleep(100 * time.Millisecond) // pretend network call
	return "done: " + url
}

urls := []string{"/users", "/orders", "/products"}
results := make([]string, len(urls))

var wg sync.WaitGroup
for i, url := range urls {
	wg.Go(func() {
		results[i] = fetch(url) // each goroutine writes its own slot
	})
}
wg.Wait() // block until all three finish
[done: /users done: /orders done: /products]
took about 100ms

Three 100 ms calls finished in about 100 ms because they ran at the same time. Called one after another, they took 301 ms.

A channel passes values between goroutines. Sending (ch <- v) and receiving (<-ch) wait for each other, which keeps the hand-off safe:

func square(nums []int, out chan<- int) {
	for _, n := range nums {
		out <- n * n
	}
	close(out) // tell the receiver no more values are coming
}

ch := make(chan int)
go square([]int{1, 2, 3, 4}, ch)
for v := range ch { // loop ends when the channel is closed
	fmt.Print(v, " ")
}
1 4 9 16

Concurrency vs parallelism

  • Concurrency is about structure: a program is split into tasks that can make progress independently, like a cook switching between three dishes.
  • Parallelism is about execution: tasks literally run at the same instant on different CPU cores, like three cooks.

Goroutines give you concurrency. The Go runtime then runs them in parallel across up to GOMAXPROCS cores, which defaults to the number of CPUs available. The fetch example above gains speed even on one core, because the goroutines spend their time waiting, not computing.

Packages and modules

A package is a folder of .go files that share a package name. A module is a collection of packages with a go.mod file.

greeter/
├── go.mod                  module example.com/greeter
├── main.go                 package main
└── internal/text/
    ├── slug.go             package text
    └── slug_test.go
// internal/text/slug.go
package text

import "strings"

// Slugify turns a title into a URL slug: "Hello Go World" -> "hello-go-world".
func Slugify(title string) string {
	return strings.Join(strings.Fields(strings.ToLower(title)), "-")
}
// main.go
package main

import (
	"fmt"

	"example.com/greeter/internal/text"
)

func main() {
	fmt.Println(text.Slugify("Golang Tutorial for Beginners"))
}
golang-tutorial-for-beginners

Packages under internal/ can only be imported by code inside the same module. Add a third-party package with go get, and run go mod tidy to clean up go.mod.

Defer, panic and recover

  • defer schedules a call to run when the function returns. Deferred calls run last-in, first-out. Use it for cleanup: defer file.Close(), defer mu.Unlock().
  • panic stops normal execution, like a crash. It is for bugs and impossible states, not for normal errors.
  • recover, called inside a deferred function, catches a panic.
func safeDivide(a, b int) (result int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("recovered: %v", r)
		}
	}()
	return a / b, nil
}

fmt.Println(safeDivide(10, 2))
fmt.Println(safeDivide(1, 0))
5 <nil>
0 recovered: runtime error: integer divide by zero

Web frameworks use recover so one bad request does not crash the server. In your own code, return errors instead of panicking.

Context package

A context.Context carries a deadline and a cancel signal across function calls and goroutines. Every HTTP request in Go has one, and database drivers and HTTP clients accept one.

func slowQuery(ctx context.Context) (string, error) {
	select {
	case <-time.After(2 * time.Second): // the "query" takes 2 s
		return "rows", nil
	case <-ctx.Done(): // the caller gave up
		return "", ctx.Err()
	}
}

ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel() // always release the context's resources

_, err := slowQuery(ctx)
fmt.Println(err, errors.Is(err, context.DeadlineExceeded))
context deadline exceeded true

The call returned after 500 ms instead of 2 s. Pass ctx as the first parameter of any function that does I/O.

Unit testing

Test files end in _test.go. Test functions start with Test and take *testing.T. Go developers usually write table-driven tests: a list of cases run in a loop.

// internal/text/slug_test.go
package text

import "testing"

func TestSlugify(t *testing.T) {
	tests := []struct {
		name  string
		input string
		want  string
	}{
		{"simple", "Hello Go", "hello-go"},
		{"extra spaces", "  Go   is  fun ", "go-is-fun"},
		{"empty", "", ""},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := Slugify(tt.input)
			if got != tt.want {
				t.Errorf("Slugify(%q) = %q, want %q", tt.input, got, tt.want)
			}
		})
	}
}

func BenchmarkSlugify(b *testing.B) {
	for b.Loop() { // Go 1.24+
		Slugify("Golang Tutorial for Beginners")
	}
}
go test ./...                       # run all tests
go test -cover ./internal/text      # with coverage
go test -bench=. -benchmem ./internal/text
ok  	example.com/greeter/internal/text	0.001s	coverage: 100.0% of statements
BenchmarkSlugify-8   	 5945242	       199.4 ns/op	     128 B/op	       3 allocs/op

The benchmark ran about 200 ns per call on my laptop, with 3 memory allocations. Your numbers will differ.

Practical Example: Build a Go REST API with Gin

We will build a small book API. Gin is a popular HTTP web framework for Go: it adds routing with path parameters, JSON binding, validation and middleware on top of the standard library's net/http.

Project setup

mkdir bookapi && cd bookapi
go mod init example.com/bookapi
go get github.com/gin-gonic/gin@v1.12.0

Gin v1.12.0 needs Go 1.25 or newer. It is the only dependency; Go downloads the packages Gin needs and records them in go.mod and go.sum.

API endpoints

MethodPathDoes
GET/healthHealth check
GET/booksList all books
GET/books/:idGet one book
POST/booksCreate a book

Implementation

package main

import (
	"net/http"
	"strconv"
	"sync"

	"github.com/gin-gonic/gin"
)

// Book is the JSON shape clients send and receive.
type Book struct {
	ID     int    `json:"id"`
	Title  string `json:"title" binding:"required"`
	Author string `json:"author" binding:"required"`
	Year   int    `json:"year" binding:"gte=1450,lte=2100"`
}

// store is an in-memory, concurrency-safe book store.
type store struct {
	mu     sync.RWMutex
	books  map[int]Book
	nextID int
}

func newStore() *store {
	return &store{books: make(map[int]Book), nextID: 1}
}

func (s *store) list() []Book {
	s.mu.RLock()
	defer s.mu.RUnlock()
	books := make([]Book, 0, len(s.books))
	for id := 1; id < s.nextID; id++ {
		if b, ok := s.books[id]; ok {
			books = append(books, b)
		}
	}
	return books
}

func (s *store) get(id int) (Book, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	b, ok := s.books[id]
	return b, ok
}

func (s *store) add(b Book) Book {
	s.mu.Lock()
	defer s.mu.Unlock()
	b.ID = s.nextID
	s.books[b.ID] = b
	s.nextID++
	return b
}

func setupRouter(s *store) *gin.Engine {
	r := gin.Default()

	r.GET("/health", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{"status": "ok"})
	})

	r.GET("/books", func(c *gin.Context) {
		c.JSON(http.StatusOK, s.list())
	})

	r.GET("/books/:id", func(c *gin.Context) {
		id, err := strconv.Atoi(c.Param("id"))
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "id must be a number"})
			return
		}
		book, ok := s.get(id)
		if !ok {
			c.JSON(http.StatusNotFound, gin.H{"error": "book not found"})
			return
		}
		c.JSON(http.StatusOK, book)
	})

	r.POST("/books", func(c *gin.Context) {
		var input Book
		if err := c.ShouldBindJSON(&input); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		c.JSON(http.StatusCreated, s.add(input))
	})

	return r
}

func main() {
	r := setupRouter(newStore())
	if err := r.SetTrustedProxies(nil); err != nil { // not behind a proxy
		panic(err)
	}
	if err := r.Run(":8080"); err != nil {
		panic(err)
	}
}

What the important parts do:

  • **Book struct tags.** json:"title" sets the JSON field name. binding:"required" and gte/lte are validation rules that Gin checks for you.
  • **store with sync.RWMutex.** Gin handles each request in its own goroutine, so the shared map needs a lock. RLock lets many readers in at once; Lock gives a writer exclusive access.
  • **gin.Default()** creates a router with logging and panic-recovery middleware.
  • **ShouldBindJSON** parses the request body into Book and runs the validation rules.
  • **SetTrustedProxies(nil)** tells Gin not to trust X-Forwarded-For headers, which removes its startup warning. Set real proxy addresses if you run behind a load balancer.

Run the application

go run .

Gin starts in debug mode and lists the routes. For production, set GIN_MODE=release and build a binary with go build -o bookapi ..

Sample requests and responses

Create a book:

curl -X POST localhost:8080/books \
  -H 'Content-Type: application/json' \
  -d '{"title":"The Go Programming Language","author":"Alan Donovan","year":2015}'
{"id":1,"title":"The Go Programming Language","author":"Alan Donovan","year":2015}

The status is 201 Created. Get it back with curl localhost:8080/books/1. A missing book returns 404 with {"error":"book not found"}, and /books/abc returns 400 with {"error":"id must be a number"}.

Send invalid data:

curl -X POST localhost:8080/books \
  -H 'Content-Type: application/json' \
  -d '{"title":"No author"}'
HTTP 400
{"error":"Key: 'Book.Author' Error:Field validation for 'Author' failed on the 'required' tag\nKey: 'Book.Year' Error:Field validation for 'Year' failed on the 'gte' tag"}

The validation rules rejected the request before it reached the store. In a real API, turn these messages into friendlier field errors, and replace the map with a database.

Popular Go Use Cases

  • REST APIs and backend services. The standard library includes a production-grade HTTP server, and one binary is easy to deploy.
  • Microservices. Fast startup, low memory use and small container images suit many small services.
  • Cloud-native applications. Kubernetes, Docker, containerd and etcd are written in Go, so their client libraries are first-class in Go.
  • CLI tools. A single binary that cross-compiles to every platform. The GitHub CLI (gh) and Hugo are examples.
  • DevOps and infrastructure tools. Terraform, Prometheus and Grafana's backend.
  • Distributed systems. Goroutines, channels and context make network-heavy code with timeouts manageable.
  • Concurrent applications. Workers, queue consumers, crawlers and proxies that handle many connections at once.

Benefits and Limitations

Advantages of Go

  • Easy to learn and easy to read, even in a large codebase
  • Fast compilation and fast execution
  • Concurrency built into the language
  • One static binary to deploy, cross-compiled from any machine
  • Strong standard library and tooling (gofmt, go test, go vet) included
  • Strict backward compatibility: Go 1 code keeps compiling on new releases

Limitations and trade-offs

  • Verbose error handling. if err != nil appears often.
  • Fewer language features than Java or Python, by design. Generics arrived only in Go 1.18 (2022), and there are no exceptions, inheritance or default arguments.
  • Smaller ecosystem for data science, machine learning and GUI apps.
  • Garbage collection means less control over memory than Rust or C++.
  • No built-in ORM or full-stack framework like Django; you assemble libraries yourself.

When to choose Go over Python, Java or Node.js

Choose Go whenChoose the other when
You need many concurrent connections with low memory usePython: data science, ML, scripting, or Django's admin and ORM speed up your project
You want a single binary and small Docker imagesJava: your company runs on the JVM and Spring, or you need its huge enterprise ecosystem
You build CLI tools, infrastructure or cloud servicesNode.js: your team writes TypeScript on both front end and back end
CPU-bound work must be faster than interpreted codePrototyping speed matters more than runtime speed

Popular Go Tools and Ecosystem

ToolWhat it does
Go modulesDependency management with go.mod and go.sum, built into the go command
gofmtFormats code in the one standard Go style. Most editors run it on save
go testRuns tests, benchmarks, fuzz tests and coverage
go vetFinds likely bugs the compiler allows, such as wrong Printf arguments
GinFast, popular HTTP framework with routing, binding and middleware
EchoHTTP framework similar to Gin, with a clean API and built-in middleware
FiberExpress.js-style framework built on fasthttp, not net/http, so some standard middleware does not plug in directly
GORMORM: maps structs to tables, with associations, hooks and migrations
sqlcYou write SQL; it generates type-safe Go functions. No ORM magic

Since Go 1.22, the standard library's http.ServeMux supports methods and path parameters (mux.HandleFunc("GET /books/{id}", ...)), so small APIs can skip a framework entirely.

Go Best Practices

  • Follow Go naming conventions. camelCase for private names, PascalCase for exported ones. Short names for short scopes (i, r, ctx). Package names are short, lowercase, single words.
  • Format code with gofmt. Never argue about style; run gofmt (or go fmt ./...) on save.
  • Handle errors explicitly. Check every error, add context with fmt.Errorf("...: %w", err), and handle it once: log it or return it, not both.
  • Write small, reusable functions with clear inputs and outputs. Accept interfaces, return concrete types.
  • Avoid unnecessary goroutines. Plain sequential code is easier to debug. Add concurrency when it solves a measured problem, and make sure every goroutine can stop.
  • Use context for cancellation and timeouts. Pass ctx to every database call, HTTP call and long-running function.
  • Write tests and benchmark performance. Use table-driven tests, go test -race in CI, and testing.B benchmarks before optimizing.
  • Keep dependencies manageable. Prefer the standard library, review what you add with go mod graph, and run go mod tidy regularly.

Frequently Asked Questions

Is Go fast?

Go compiles to native code, so it is usually much faster than interpreted languages like Python for CPU-heavy work. It is typically a little slower than C, C++ or Rust because of garbage collection. For web services, the database is usually the bottleneck, not Go.

Is Go hard to learn?

No. Go is one of the easier languages to learn because it is small. A developer who knows another language can write useful Go within a few days. The parts that take longer are concurrency patterns and idiomatic error handling.

Is Go object-oriented?

Partly. Go has types, methods and interfaces, but no classes or inheritance. You build behaviour by composition: embedding one struct in another and satisfying small interfaces.

How is Go's concurrency different from threads?

Goroutines are managed by the Go runtime, not the operating system. They start with a small stack that grows as needed, so a program can run many thousands of them. The runtime spreads them across a few OS threads.

Which Go web framework should I use?

Start with the standard library's net/http. Choose Gin or Echo when you want built-in binding, validation and middleware. Use Fiber only if its Express-style API matters more than net/http compatibility.

Is Go good for a career?

Go is widely used in backend, cloud, platform and DevOps engineering, especially at companies that run Kubernetes and microservices. It pairs well with Python: many backend teams use Python for product features and Go for performance-sensitive services and tooling.

Conclusion

Go gives you a short list of features that work well together: simple syntax, static types, fast compiled binaries, built-in concurrency with goroutines and channels, and tooling that formats, tests and vets your code out of the box. That is why it runs so much of today's cloud infrastructure.

Next steps:

  1. Take the official Tour of Go to practise the basics in your browser.
  2. Read Effective Go for idiomatic style.
  3. Extend the book API: add PUT and DELETE, store books in PostgreSQL with sqlc, and write handler tests with net/http/httptest.
  4. Build a small CLI tool and cross-compile it for Linux, macOS and Windows.

Related reading