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.
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
implementskeyword. - Explicit error handling. Functions return errors as normal values. There are no exceptions to forget to catch.
- Built-in testing.
go testruns 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 buildbuilds 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 yourPATH:
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
| Type | Example | Notes |
|---|---|---|
int, int64 | 42 | Whole numbers |
float64 | 19.99 | Decimal numbers |
string | "Go" | Immutable UTF-8 text |
bool | true | true 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
deferschedules 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().panicstops 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
| Method | Path | Does |
|---|---|---|
| GET | /health | Health check |
| GET | /books | List all books |
| GET | /books/:id | Get one book |
| POST | /books | Create 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:
- **
Bookstruct tags.**json:"title"sets the JSON field name.binding:"required"andgte/lteare validation rules that Gin checks for you. - **
storewithsync.RWMutex.** Gin handles each request in its own goroutine, so the shared map needs a lock.RLocklets many readers in at once;Lockgives a writer exclusive access. - **
gin.Default()** creates a router with logging and panic-recovery middleware. - **
ShouldBindJSON** parses the request body intoBookand runs the validation rules. - **
SetTrustedProxies(nil)** tells Gin not to trustX-Forwarded-Forheaders, 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
contextmake 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 != nilappears 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 when | Choose the other when |
|---|---|
| You need many concurrent connections with low memory use | Python: data science, ML, scripting, or Django's admin and ORM speed up your project |
| You want a single binary and small Docker images | Java: your company runs on the JVM and Spring, or you need its huge enterprise ecosystem |
| You build CLI tools, infrastructure or cloud services | Node.js: your team writes TypeScript on both front end and back end |
| CPU-bound work must be faster than interpreted code | Prototyping speed matters more than runtime speed |
Popular Go Tools and Ecosystem
| Tool | What it does |
|---|---|
| Go modules | Dependency management with go.mod and go.sum, built into the go command |
| gofmt | Formats code in the one standard Go style. Most editors run it on save |
| go test | Runs tests, benchmarks, fuzz tests and coverage |
| go vet | Finds likely bugs the compiler allows, such as wrong Printf arguments |
| Gin | Fast, popular HTTP framework with routing, binding and middleware |
| Echo | HTTP framework similar to Gin, with a clean API and built-in middleware |
| Fiber | Express.js-style framework built on fasthttp, not net/http, so some standard middleware does not plug in directly |
| GORM | ORM: maps structs to tables, with associations, hooks and migrations |
| sqlc | You 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.
camelCasefor private names,PascalCasefor 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(orgo 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
ctxto every database call, HTTP call and long-running function. - Write tests and benchmark performance. Use table-driven tests,
go test -racein CI, andtesting.Bbenchmarks before optimizing. - Keep dependencies manageable. Prefer the standard library, review what you add with
go mod graph, and rungo mod tidyregularly.
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:
- Take the official Tour of Go to practise the basics in your browser.
- Read Effective Go for idiomatic style.
- Extend the book API: add
PUTandDELETE, store books in PostgreSQL withsqlc, and write handler tests withnet/http/httptest. - Build a small CLI tool and cross-compile it for Linux, macOS and Windows.