Introduction to Go & Setup
What is Go?
Go (often called Golang) is an open-source, statically typed, compiled programming language developed at Google. It was designed to solve problems encountered in large-scale software development: slow build times, complex dependency management, and the difficulty of writing efficient concurrent code.
Think of Go as a language that combines the performance of C++ with the simplicity and readability of Python.
Why Go?
- Excellent Performance: Since it is compiled directly to machine code, Go is incredibly fast and efficient.
- Built-in Concurrency: Using "Goroutines" (lightweight threads), Go can handle thousands of tasks simultaneously, making it perfect for high-traffic servers.
- Strong Typing: Being statically typed means errors are caught during compilation rather than at runtime, leading to more stable code.
- Fast Compilation: Go was designed for fast build times, even for massive projects.
- Ideal Use-Cases: It is the industry standard for cloud-native development, microservices, Kubernetes, Docker, and high-performance CLI tools.
Installation
To get started, download the installer from go.dev/dl. Follow the installation wizard for your OS (Windows, macOS, or Linux).
Once installed, verify the installation by opening your terminal and running:
go version go version go1.22.3 linux/amd64
Your First Go Program
Create a file named main.go and enter the following code:
package main import "fmt" func main() { fmt.Println("Hello, Go Mastery!") fmt.Println("Fast, simple, and concurrent!") }
Fast, simple, and concurrent!
Breaking Down the Code
package main: Every Go file must start with a package declaration. Themainpackage tells Go that this file should compile as an executable program rather than a library.import "fmt": Thefmt(format) package contains functions for formatting text and printing to the console.func main(): This is the entry point of the program. When you run the program, the code inside this function is executed first.fmt.Println(): A function from the fmt package that prints text to the console followed by a new line.
π» Try It Yourself - Multi-Language Compiler
Practice Go and many other programming languages right here in your browser! Switch between languages, modify the code, and click "Run" to see results instantly.
π‘ Practice Tips:
- Switch to Go in the language selector and try the concurrency examples
- Experiment with Go's simple syntax and strong typing
- Try other languages like Rust, C++, or Java to compare performance features
- Use the "Load Example" button to see Go-specific code samples
- Use Ctrl+Enter to quickly run your code
Running Your Code
Go provides two main ways to run your code:
1. Direct Run (Development): Use this to test your code quickly without creating a permanent binary file.
go run main.go
2. Build (Production): This compiles the code into a standalone executable binary that can be run on any machine without Go installed.
go build main.go ./main
Understanding Go Modules
In modern Go, we use Modules to manage dependencies. A module is a collection of related Go packages that are versioned together. To start a new project, you must initialize a module:
go mod init hello-world
This creates a go.mod file, which tracks your project's dependencies and Go version.
Complete these steps to verify your environment:
- Install Go and verify with
go version. - Create a project folder and initialize a module using
go mod init hello. - Create
main.go, write a program that prints your name and your favorite goal for learning Go. - Run it using
go run main.go.
Variables & Data Types
Introduction to Variables
Variables are used to store data in memory. In Go, every variable has a specific data type which determines what kind of value it can hold.
For example:
- string β stores text
- int β stores whole numbers
- float64 β stores decimal numbers
- bool β stores true or false values
Variables make programs dynamic because values can change while the program runs.
Declaring Variables
Go offers multiple ways to declare variables. The short declaration := is the most common inside functions.
var name string = "Gopher" age := 10 // Type inferred as int
In the example above:
nameis explicitly declared as astringageuses type inference, so Go automatically detects the type
Different Ways to Declare Variables
Go supports several styles of variable declaration depending on the situation.
// Explicit type declaration var city string = "Tokyo" // Type inference var score = 95 // Short declaration language := "Go"
The short declaration syntax := can only be used inside functions.
Multiple Variable Declarations
You can declare multiple variables in a single line.
name, age := "Alice", 21
This is useful when working with multiple related values.
Understanding Data Types
Data types define the kind of data stored inside a variable.
| Type | Description | Example |
|---|---|---|
| int | Whole numbers | 10, -5, 200 |
| float64 | Decimal numbers | 3.14, 9.99 |
| string | Text values | "Hello" |
| bool | True or false | true, false |
Strings in Go
Strings are sequences of characters surrounded by double quotes.
message := "Welcome to Go"
fmt.Println(message)
Strings are commonly used for displaying messages, usernames, and text data.
Integer and Float Values
Integers store whole numbers while floats store decimal values.
age := 25 price := 19.99
Use integers when decimals are not needed and floats when precision with decimals is required.
Boolean Values
Boolean variables can only contain two values: true or false.
isLoggedIn := true isAdmin := false
Booleans are commonly used in conditions and decision-making statements.
Zero Values
Variables declared without an initial value are given their zero value.
| Data Type | Zero Value |
|---|---|
| int | 0 |
| float64 | 0 |
| string | "" |
| bool | false |
var number int var username string var active bool fmt.Println(number) fmt.Println(username) fmt.Println(active)
The output will be:
0 false
Constants
Constants are values that cannot be changed after they are declared.
const pi = 3.14159 const appName = "Go Learning"
Use constants for fixed values such as mathematical values, configuration names, or application titles.
Naming Conventions
Good variable names make code easier to read and maintain.
- Use meaningful names
- Avoid unnecessary abbreviations
- Use camelCase for multi-word names
userName := "John"
totalPrice := 250
Common Beginner Mistakes
- Using
:=outside functions - Forgetting to initialize variables properly
- Using the wrong data type
- Trying to change constant values
Practice Exercise
Create variables for the following:
- Your name
- Your age
- Your favorite programming language
- Whether you are learning Go
Print all values using fmt.Println().
package main import "fmt" func main() { name := "Alex" age := 22 language := "Go" learning := true fmt.Println(name) fmt.Println(age) fmt.Println(language) fmt.Println(learning) }
Summary
In this lecture, you learned:
- How to declare variables in Go
- Different Go data types
- How zero values work
- How constants are used
- Best practices for naming variables
Control Flow
Control flow allows your program to make decisions, repeat blocks of code, and branch into different execution paths based on conditions.
Conditional Branching: If / Else
The if` statement is the most basic way to control flow. In Go, the condition does not require parentheses.
if age >= 18 { fmt.Println("You are an adult.") } else if age >= 13 { fmt.Println("You are a teenager.") } else { fmt.Println("You are a child.") }
The "If" with a Short Statement
Go allows a unique feature where you can declare a local variable right inside the if` statement. This variable is only available within the scope of that if/else block.
if val := getNumber(); val > 10 { fmt.Println("Value is high: ", val) } else { fmt.Println("Value is low: ", val) }
The Switch Statement
A switch` statement is a cleaner way to write multiple if/else` conditions. In Go, the switch breaks automaticallyβyou don't need to write break` after every case.
switch day := "Monday"; day { case "Monday": fmt.Println("Start of the work week!") case "Friday": fmt.Println("Weekend is almost here!") case "Saturday", "Sunday": fmt.Println("It's the weekend!") default: fmt.Println("Just another mid-week day.") }
The Only Loop: For
Go keeps it simple: it only has one looping construct, the for` loop. However, it is incredibly versatile and can be used in three different ways.
1. Standard C-Style Loop
Used when you know exactly how many times you want to iterate.
for i := 0; i < 5; i++ {
fmt.Println(i)
}
2. While-Style Loop
Go doesn't have a while` keyword. Instead, you just omit the initialization and post-statement parts of the for` loop.
// while-style loop n := 1 for n < 100 { n *= 2 fmt.Println(n) }
3. Infinite Loop
If you omit all conditions, the loop runs forever until you manually break` or return` from the function.
for { fmt.Println("This runs forever...") break // Stop the loop immediately }
Loop Control: Break & Continue
- break: Immediately exits the loop.
- continue: Skips the rest of the current iteration and jumps to the next loop cycle.
Write a program that prints numbers from 1 to 20. However:
- If the number is divisible by 3, print "Fizz" instead of the number.
- If the number is divisible by 5, print "Buzz".
- If divisible by both, print "FizzBuzz".
for` loop and if/else` logic.
Functions & Multiple Returns
Introduction to Functions
Functions are reusable blocks of code designed to perform a specific task. They help organize programs into smaller, manageable sections and reduce code repetition.
Instead of writing the same code multiple times, you can place it inside a function and call it whenever needed.
Basic Function Syntax
In Go, functions are declared using the func keyword.
func greet() { fmt.Println("Welcome to Go!") }
To execute the function, call it by its name.
greet()
Functions with Parameters
Parameters allow functions to accept input values.
func greetUser(name string) { fmt.Println("Hello", name) }
Calling the function:
greetUser("Alice")
Output:
Hello Alice
Functions with Return Values
Functions can return data back to the caller using the return keyword.
func add(a, b int) int { return a + b }
Using the returned value:
result := add(5, 3) fmt.Println(result)
Why Functions Matter
Functions improve:
- Code reusability
- Program organization
- Readability
- Debugging and maintenance
Large applications rely heavily on functions to separate logic into smaller units.
Multiple Return Values
One of Go's most powerful features is the ability for functions to return multiple values.
This is commonly used for returning a result together with an error value.
func divide(a, b float64) (float64, error) { if b == 0 { return 0, errors.New("cannot divide by zero") } return a / b, nil }
Understanding the Function
In the example above:
- The function accepts two
float64numbers - It returns two values:
- A division result
- An error value
- If division by zero occurs, an error is returned
- If successful, the error value becomes
nil
Using Multiple Return Values
When calling a function with multiple returns, store each value in separate variables.
result, err := divide(10, 2) if err != nil { fmt.Println(err) } else { fmt.Println(result) }
Output:
5
Handling Errors Properly
Error handling is extremely important in Go programming.
Instead of using exceptions like some other languages, Go encourages developers to explicitly check errors.
result, err := divide(5, 0) if err != nil { fmt.Println("Error:", err) return } fmt.Println(result)
Output:
Error: cannot divide by zero
Ignoring Return Values
Sometimes you may only need one of the returned values. Go allows unused values to be ignored using the blank identifier _.
result, _ := divide(20, 4) fmt.Println(result)
The underscore tells Go to ignore the second return value.
Named Return Values
Go also supports named return variables.
func rectangle(width, height int) (area int) { area = width * height return }
This style can make some functions easier to read, but overusing it may reduce clarity.
Variadic Functions
Variadic functions can accept a variable number of arguments.
func sum(numbers ...int) int { total := 0 for _, num := range numbers { total += num } return total }
Calling the function:
fmt.Println(sum(1, 2, 3, 4))
Output:
10
Anonymous Functions
Go supports functions without names, called anonymous functions.
message := func() { fmt.Println("Anonymous function executed") } message()
Anonymous functions are useful for short tasks and callbacks.
Scope of Variables
Variables declared inside a function are local to that function.
func test() {
number := 10
fmt.Println(number)
}
The variable number cannot be accessed outside the function.
Common Beginner Mistakes
- Forgetting to return values
- Ignoring important errors
- Using wrong parameter types
- Declaring variables that are never used
- Confusing local and global variables
Practice Exercise
Create a function called multiply that accepts two integers and returns their product.
Then create another function called subtract that returns both the result and an error if the result becomes negative.
func multiply(a, b int) int { return a * b }
Summary
In this lecture, you learned:
- How functions work in Go
- How to use parameters and return values
- How multiple return values work
- How Go handles errors
- How variadic and anonymous functions operate
Pointers & Structs
Introduction to Structs
Structs are the primary way to create custom data types in Go. They allow you to group related data together under a single name, similar to objects or classes in other programming languages.
Structs are widely used for:
- Representing users, products, and database records
- Building APIs and web applications
- Organizing related data cleanly
- Creating reusable program models
Structs
A struct is defined using the type and struct keywords.
type User struct { ID int Name string Email string Age int }
Each field inside the struct has:
- A field name
- A data type
Creating Struct Values
You can create struct values using struct literal syntax.
var u1 User // zero value u2 := User{ ID: 1, Name: "Alice", } u3 := User{ // order-based (not recommended) 2, "Bob", "bob@example.com", 28, }
Zero Values in Structs
When a struct is created without assigning values, Go automatically assigns zero values.
| Type | Zero Value |
|---|---|
| int | 0 |
| string | "" |
| bool | false |
| pointer | nil |
Accessing and Modifying Fields
Struct fields are accessed using dot notation.
u := User{
ID: 1,
Name: "Alice",
}
fmt.Println(u.Name) // Alice
u.Age = 25 // modify field
fmt.Println(u) // {1 Alice 25}
Comparing Structs
Structs with comparable fields can be compared directly using ==.
u1 := User{ID: 1, Name: "Alice"}
u2 := User{ID: 1, Name: "Alice"}
fmt.Println(u1 == u2) // true
Nested Structs
Structs can contain other structs as fields.
type Address struct { City string Country string } type Employee struct { Name string Address Address }
Introduction to Pointers
In Go, everything is passed by value. This means copies are created when values are passed to functions.
Pointers allow us to work directly with memory addresses instead of copies.
Pointers
type User struct { ID int Name string } u := User{ID: 1, Name: "Alice"} p := &u // pointer to u fmt.Println((*p).Name) // explicit dereference fmt.Println(p.Name) // convenience syntax
Understanding Memory Addresses
The & operator gets the address of a variable,
while the * operator accesses the value stored at that address.
x := 10 p := &x fmt.Println(p) // memory address fmt.Println(*p) // value stored at address
Creating Pointers Directly
You can create pointers directly using struct literals.
u := &User{
ID: 2,
Name: "Bob",
}
u.Name = "Robert"
fmt.Println(u.Name) // Robert
Why Use Pointers with Structs?
- Avoid copying large structs
- Improve application performance
- Allow functions to modify original data
- Useful when sharing data across functions
Passing Structs to Functions
Passing a struct without pointers creates a copy.
func printUser(user User) {
fmt.Println(user.Name)
}
Any modifications inside the function will not affect the original struct.
Modifying Structs with Pointers
Pointers allow functions to update the original struct value.
func updateName(user *User, newName string) { user.Name = newName } u := User{Name: "Alice"} updateName(&u, "Alicia") fmt.Println(u.Name) // Alicia
Common Mistake: Forgetting Pointers
Forgetting pointers causes functions to modify copies instead of originals.
func badUpdate(user User) { user.Name = "Changed" } u := User{Name: "Alice"} badUpdate(u) fmt.Println(u.Name) // Still "Alice"
Nil Pointers
A pointer that does not reference any value is called a nil pointer.
var p *User fmt.Println(p == nil) // true
Dereferencing a nil pointer will cause a runtime panic.
Struct Tags
Struct tags provide metadata for frameworks and libraries.
They are commonly used with:
- JSON encoding
- Database ORM tools
- Validation libraries
type User struct { ID int `json:"id"` Name string `json:"name,omitempty"` Email string `json:"-"` }
Embedded Structs (Composition)
Go uses composition instead of traditional inheritance. Embedded structs allow one struct to include another struct.
type Address struct { City string Country string } type User struct { ID int Name string Address } u := User{ Name: "Alice", Address: Address{ City: "Ludhiana", Country: "India", }, } fmt.Println(u.City)
Benefits of Composition
- Encourages reusable code
- Reduces tight coupling
- Provides flexible designs
- Preferred over inheritance in Go
Best Practices
- Use pointers for large structs
- Prefer named field initialization
- Keep structs focused on one purpose
- Use composition instead of complex nesting
- Handle nil pointers carefully
Common Mistakes
- Forgetting to dereference pointers
- Passing large structs by value unnecessarily
- Using order-based struct initialization
- Dereferencing nil pointers
- Overcomplicating struct designs
Mini Practice
Try creating the following:
- A
Bookstruct with title and author - A function that updates a struct using pointers
- A nested struct example
- A struct with JSON tags
- An embedded struct using composition
Arrays, Slices & Maps
Introduction to Collections in Go
Go provides several built-in data structures for storing and organizing data. The most commonly used are arrays, slices, and maps.
These structures help developers:
- Store multiple values efficiently
- Organize related data
- Process large collections easily
- Build scalable applications
Arrays
Arrays are fixed-size collections of elements of the same type.
var numbers [5]int
This creates an array that can store exactly 5 integers.
Initializing Arrays
Arrays can be initialized during declaration.
numbers := [5]int{1, 2, 3, 4, 5}
Each value is stored in a numbered position called an index.
Accessing Array Elements
Array elements are accessed using indexes starting from 0.
numbers := [3]int{10, 20, 30} fmt.Println(numbers[0]) // 10 fmt.Println(numbers[2]) // 30
Modifying Array Values
numbers := [3]int{1, 2, 3}
numbers[1] = 50
fmt.Println(numbers)
Array Length
The len() function returns the number of elements in an array.
arr := [4]int{1, 2, 3, 4} fmt.Println(len(arr)) // 4
Looping Through Arrays
Arrays are commonly traversed using loops.
numbers := [3]int{10, 20, 30} for i, value := range numbers { fmt.Println(i, value) }
Limitations of Arrays
- Fixed size
- Cannot grow dynamically
- Less flexible for real-world applications
Because of these limitations, Go developers usually prefer slices.
Slices: Dynamic Arrays
Slices are flexible and dynamic views over arrays. They are one of the most important data structures in Go.
s := []int{1, 2, 3}
s = append(s, 4)
Why Slices Are Important
- Dynamic size
- Easy to modify
- Efficient memory handling
- Widely used in Go libraries
Creating Slices
Slices can be created from arrays or directly using literals.
nums := []int{1, 2, 3, 4}
Slice Operations
Slices support many useful operations.
nums := []int{1, 2, 3, 4, 5} fmt.Println(nums[1:4]) // [2 3 4]
Slice syntax:
[start:end]- Start index is included
- End index is excluded
Appending Elements
The append() function adds new elements to a slice.
s := []int{1, 2}
s = append(s, 3)
s = append(s, 4, 5)
fmt.Println(s)
Slice Length and Capacity
Slices have both length and capacity.
s := []int{1, 2, 3} fmt.Println(len(s)) // length fmt.Println(cap(s)) // capacity
Length represents the number of elements, while capacity represents the available underlying storage.
Using make() with Slices
The make() function creates slices with predefined length and capacity.
s := make([]int, 5, 10) fmt.Println(len(s)) fmt.Println(cap(s))
Copying Slices
The copy() function copies elements between slices.
src := []int{1, 2, 3} dest := make([]int, len(src)) copy(dest, src) fmt.Println(dest)
Maps: Key-Value Pairs
Maps store data as key-value pairs. They are similar to dictionaries or hash tables in other languages.
m := make(map[string]int) m["apples"] = 5
Creating Maps
scores := map[string]int{ "Alice": 90, "Bob": 85, }
Accessing Map Values
fmt.Println(scores["Alice"])
Checking If a Key Exists
Go provides a special syntax for checking map keys.
value, exists := scores["John"]
fmt.Println(value)
fmt.Println(exists)
Deleting Map Entries
The delete() function removes keys from maps.
delete(scores, "Bob")
Looping Through Maps
for key, value := range scores { fmt.Println(key, value) }
Nested Maps
Maps can also store other maps.
students := map[string]map[string]int{ "Alice": { "Math": 95, }, }
Choosing Between Arrays, Slices, and Maps
| Structure | Use Case |
|---|---|
| Array | Fixed-size collections |
| Slice | Dynamic ordered data |
| Map | Fast key-value lookups |
Best Practices
- Prefer slices over arrays in most applications
- Use maps for fast lookups
- Initialize maps before use
- Use meaningful keys in maps
- Avoid unnecessary slice copying
Common Mistakes
- Accessing invalid indexes
- Using uninitialized maps
- Confusing length and capacity
- Forgetting that arrays have fixed size
- Modifying slices unexpectedly through shared backing arrays
Mini Practice
Try creating the following:
- An array storing 5 numbers
- A slice that grows using
append() - A map storing student scores
- A loop that prints slice values
- A nested map representing user data
Methods & Interfaces
Introduction to Methods
Methods in Go are functions that are attached to a specific type. They allow you to define behavior for structs and custom types. Methods make code more organized, reusable, and easier to understand.
In many programming languages, methods belong to classes. Go does not have classes, but it allows methods to be attached directly to types.
package main import "fmt" type Person struct { Name string } func (p Person) Greet() { fmt.Println("Hello,", p.Name) } func main() { user := Person{Name: "John"} user.Greet() }
In the example above:
Personis a struct.Greet()is a method attached to thePersontype.p Personis called the receiver.
Understanding Receivers
A receiver connects a method to a type. Go supports two kinds of receivers:
- Value Receivers
- Pointer Receivers
Value Receiver
A value receiver works with a copy of the original value. Changes made inside the method do not affect the original data.
type Counter struct { Value int } func (c Counter) Increment() { c.Value++ }
Even after calling Increment(), the original value remains unchanged because Go passes a copy.
Pointer Receiver
A pointer receiver allows the method to modify the original struct value.
func (c *Counter) Increment() {
c.Value++
}
Pointer receivers are commonly used when:
- You want to modify the original object.
- The struct is large and copying it would waste memory.
Methods on Custom Types
Methods are not limited to structs. You can attach methods to custom types as well.
type Celsius float64 func (c Celsius) ToFahrenheit() float64 { return (float64(c) * 9/5) + 32 }
This allows custom types to have their own behavior and utility methods.
Interfaces
Go uses implicit interfaces. If a type implements the methods, it implements the interface automatically. There is no need to explicitly declare implementation like in Java or C#.
type Shape interface { Area() float64 }
An interface defines a set of method signatures. Any type that contains those methods satisfies the interface.
Implementing an Interface
Let us create a rectangle type that implements the Shape interface.
type Rectangle struct { Width float64 Height float64 } func (r Rectangle) Area() float64 { return r.Width * r.Height }
Because Rectangle has an Area() method, it automatically satisfies the Shape interface.
Multiple Types Using One Interface
Interfaces become powerful when different types share the same behavior.
type Circle struct { Radius float64 } func (c Circle) Area() float64 { return 3.14 * c.Radius * c.Radius }
Now both Rectangle and Circle satisfy the same interface.
func PrintArea(s Shape) {
fmt.Println("Area:", s.Area())
}
The function above accepts any type that implements the Shape interface.
Why Interfaces Are Important
Interfaces are heavily used in Go because they:
- Promote reusable code.
- Allow flexible program design.
- Reduce tight coupling between components.
- Make testing easier using mock implementations.
- Enable polymorphism.
The Empty Interface
The empty interface can hold values of any type because it defines zero methods.
var data interface{} data = 42 data = "Hello" data = true
Before Go introduced generics, the empty interface was widely used for handling unknown data types.
Type Assertions
Type assertions allow you to retrieve the concrete value stored inside an interface.
var value interface{} = "Go" str := value.(string) fmt.Println(str)
You can also safely check types using the two-value form.
str, ok := value.(string) if ok { fmt.Println("Value:", str) }
Type Switches
Type switches make it easier to work with multiple possible types stored inside interfaces.
switch v := value.(type) { case string: fmt.Println("String:", v) case int: fmt.Println("Integer:", v) default: fmt.Println("Unknown type") }
Real-World Usage of Interfaces
Interfaces are everywhere in Goβs standard library. Examples include:
io.Readerio.Writerfmt.Stringerhttp.Handler
These interfaces allow completely different types to work together through shared behavior.
Best Practices
- Keep interfaces small and focused.
- Use pointer receivers when modifying data.
- Name interfaces based on behavior.
- Do not create interfaces too early.
- Prefer composition over complex inheritance patterns.
Summary
Methods define behavior for types, while interfaces define shared behavior between multiple types. Together, they form the foundation of abstraction and reusable design in Go applications.
Understanding methods and interfaces is essential for building scalable applications, APIs, concurrent systems, and production-level Go software.
Error Handling
Introduction to Error Handling
Error handling is one of the most important concepts in Go. Unlike many programming languages that use exceptions, Go uses explicit error checking. This approach makes programs easier to understand and more predictable.
In Go, functions commonly return two values:
- The actual result.
- An error value.
If the error is nil, the operation succeeded. Otherwise, something went wrong.
The Idiomatic Way
Go developers follow a simple and readable pattern for handling errors.
val, err := doSomething() if err != nil { // Handle error return err }
This pattern is called the idiomatic way because it is the standard style used throughout Go applications and the Go standard library.
Why Go Uses Explicit Errors
Go avoids hidden control flow caused by exceptions. Instead of automatic exception handling, errors are returned as normal values.
This provides several advantages:
- Programs become easier to debug.
- Error handling is visible in the code.
- Developers are forced to think about failure cases.
- The control flow remains simple and predictable.
Creating Errors
The errors package is commonly used to create custom error messages.
package main import ( "errors" "fmt" ) func divide(a, b float64) (float64, error) { if b == 0 { return 0, errors.New("cannot divide by zero") } return a / b, nil } func main() { result, err := divide(10, 0) if err != nil { fmt.Println("Error:", err) return } fmt.Println("Result:", result) }
The errors.New() function creates a simple error value containing a message.
Using fmt.Errorf()
Sometimes you need formatted error messages that include variables or additional information.
err := fmt.Errorf("user %s not found", username)
This works similarly to fmt.Printf() but returns an error value instead of printing output.
Returning Multiple Values
Go functions frequently return multiple values. Error handling is one of the biggest reasons for this design.
func getUser(id int) (string, error) { if id == 0 { return "", errors.New("invalid user id") } return "John", nil }
The first value is the actual result, while the second value reports whether the operation failed.
Ignoring Errors
Sometimes developers intentionally ignore returned values using the blank identifier _.
value, _ := doSomething()
However, ignoring errors is generally discouraged unless you are absolutely certain the error does not matter.
Panic
Go provides the panic() function for unrecoverable situations. A panic immediately stops normal execution of the program.
panic("something went terribly wrong")
Panic should be used carefully. Most errors should be handled normally instead of crashing the program.
Recover
The recover() function allows a program to regain control after a panic. It is commonly used with deferred functions.
func safe() { defer func() { if r := recover(); r != nil { fmt.Println("Recovered:", r) } }() panic("unexpected error") }
This prevents the application from crashing completely.
Deferred Cleanup
The defer keyword is frequently used during error handling to ensure cleanup code always runs.
file, err := os.Open("data.txt")
if err != nil {
return err
}
defer file.Close()
Even if an error occurs later, the file will still be closed properly.
Custom Error Types
Go allows developers to create custom error types by implementing the Error() method.
type ValidationError struct { Message string } func (v ValidationError) Error() string { return v.Message }
Any type that implements the Error() method satisfies the built-in error interface.
Error Wrapping
Modern Go applications often wrap errors to preserve context and debugging information.
if err != nil { return fmt.Errorf("failed to load config: %w", err) }
The %w verb wraps the original error so it can still be inspected later.
Checking Wrapped Errors
The errors.Is() function checks whether an error matches another error.
if errors.Is(err, os.ErrNotExist) {
fmt.Println("File does not exist")
}
This is useful when working with wrapped or nested errors.
Best Practices
- Always check returned errors.
- Keep error messages clear and meaningful.
- Avoid using panic for normal application logic.
- Use defer for cleanup operations.
- Wrap errors when adding additional context.
- Return errors instead of hiding them.
Common Beginner Mistakes
- Ignoring errors completely.
- Using panic too often.
- Returning vague error messages.
- Forgetting to close files or connections.
- Not checking for nil values.
Summary
Error handling in Go is simple, explicit, and reliable. Instead of exceptions, Go treats errors as regular values that developers handle directly.
Mastering error handling is essential for building stable APIs, command-line tools, servers, and production-level Go applications.
Generics
Introduction to Generics
Generics allow you to write flexible and reusable code that works with multiple data types without duplicating logic. Before generics were introduced in Go, developers often had to write separate functions for different types such as int, float64, and string.
Generics solve this problem by allowing functions, structs, and data structures to work with type parameters.
This feature was officially introduced in Go 1.18 and became one of the biggest additions to the language.
Why Generics Are Important
Without generics, developers usually:
- Repeated the same code for multiple types.
- Used empty interfaces which reduced type safety.
- Wrote large amounts of boilerplate code.
Generics make code:
- Reusable
- Cleaner
- Safer
- More maintainable
- Easier to scale
Understanding Type Parameters
Type parameters are placeholders for actual types. They are written inside square brackets [].
func Print[T any](value T) {
fmt.Println(value)
}
In this example:
Tis a type parameter.anymeans the function accepts any type.
The function can now work with integers, strings, floats, booleans, and custom structs.
Print(10)
Print("Hello")
Print(true)
Generic Functions
Generic functions are functions that work with multiple data types using type parameters.
func Sum[T int | float64](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
fmt.Println(Sum([]int{1, 2, 3})) // 6
fmt.Println(Sum([]float64{1.5, 2.5})) // 4
The function above works for both integer slices and floating-point slices.
Instead of writing two separate functions, generics allow one reusable solution.
How Type Inference Works
Go can automatically detect the type parameter based on the values passed into the function.
result := Sum([]int{1, 2, 3})
Go automatically understands that T is int.
You can also specify the type manually.
result := Sum[int]([]int{1, 2, 3})
Type Constraints
Type constraints define which types are allowed for a generic parameter.
type Number interface {
int | int64 | float32 | float64
}
func Max[T Number](a, b T) T {
if a > b {
return a
}
return b
}
Here:
Numberis a custom constraint.- The function only accepts numeric types.
This prevents invalid types from being used.
The any Keyword
The any keyword is simply an alias for interface{}.
func Display[T any](data T) {
fmt.Println(data)
}
It means the generic parameter can accept any possible type.
Generic Types
Generics are not limited to functions. Structs and custom types can also use type parameters.
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(x T) {
s.items = append(s.items, x)
}
func (s *Stack[T]) Pop() T {
n := len(s.items) - 1
x := s.items[n]
s.items = s.items[:n]
return x
}
This stack can now store any type:
- Integers
- Strings
- Booleans
- Structs
var nums Stack[int]
nums.Push(10)
var names Stack[string]
names.Push("Alice")
Generic Data Structures
Generics are especially useful for reusable data structures such as:
- Stacks
- Queues
- Linked Lists
- Trees
- Hash Maps
Without generics, developers often needed to duplicate these structures for each data type.
Using Comparable Types
Go provides the built-in comparable constraint for types that support equality operations.
func Equal[T comparable](a, b T) bool {
return a == b
}
This function works with types that can be compared using ==.
Combining Interfaces and Generics
Generics and interfaces can work together to create powerful abstractions.
type Printer interface {
Print()
}
func Show[T Printer](item T) {
item.Print()
}
Any type that implements the Print() method can be used with the generic function.
Benefits of Generics
- Reduces duplicated code.
- Improves type safety.
- Makes reusable libraries easier to build.
- Provides cleaner APIs.
- Eliminates unnecessary type casting.
Limitations of Generics
Although generics are powerful, they should not be overused.
- Some code becomes harder to read.
- Complex constraints can confuse beginners.
- Not every function needs generics.
Use generics only when they genuinely improve code reuse and maintainability.
Common Beginner Mistakes
- Using generics for very simple functions.
- Creating overly complex constraints.
- Confusing interfaces with generics.
- Forgetting that type inference exists.
- Using
anywhen stricter constraints are better.
Real-World Use Cases
Generics are commonly used in:
- Utility libraries
- Database query builders
- Reusable collections
- Frameworks
- Data processing systems
- Algorithms and sorting utilities
Practice
Write a generic Filter function that works for any slice type.
Your function should:
- Accept a slice of any type.
- Accept a condition function.
- Return only matching elements.
func Filter[T any](items []T, fn func(T) bool) []T {
var result []T
for _, item := range items {
if fn(item) {
result = append(result, item)
}
}
return result
}
Summary
Generics allow Go developers to write reusable, type-safe, and maintainable code without duplication. By using type parameters and constraints, you can create flexible functions and data structures that work across many data types.
Understanding generics is important for modern Go development, especially when building reusable libraries, scalable systems, and production-grade applications.
Concurrency & Goroutines
Goroutines
Use the go keyword to run a function concurrently.
go sayHello()
Channels & Synchronization
Channels
Channels are the pipes that connect concurrent goroutines.
ch := make(chan int) ch <- 42 // Send val := <-ch // Receive
Context & Cancellation
Context Basics
import "context"
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
select {
case <-time.After(3 * time.Second):
fmt.Println("done")
case <-ctx.Done():
fmt.Println("timeout:", ctx.Err())
}WithCancel
ctx, cancel := context.WithCancel(context.Background())
go func() {
for {
select {
case <-ctx.Done():
return
default:
doWork()
}
}
}()
time.Sleep(1 * time.Second)
cancel() // signal goroutine to stopPassing Values
ctx := context.WithValue(context.Background(), "userID", 42)
id := ctx.Value("userID").(int)Build an HTTP client that cancels requests after 5 seconds using context.
Modules & Testing
Writing Tests
func TestAdd(t *testing.T) { if Add(1, 2) != 3 { t.Errorf("Failed!") } }
HTTP Servers
Basic HTTP Server
package main
import (
"fmt"
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %s!", r.URL.Path[1:])
}
func main() {
http.HandleFunc("/", handler)
http.ListenAndServe(":8080", nil)
}JSON Responses
type User struct {
Name string `json:"name"`
Age int `json:"age"`
}
func userHandler(w http.ResponseWriter, r *http.Request) {
u := User{Name: "Ahmed", Age: 25}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(u)
}Middleware
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Println(r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
}Build a simple URL shortener with in-memory storage.
REST API with Gin
Setup
go get -u github.com/gin-gonic/gin
Basic Server
package main
import "github.com/gin-gonic/gin"
func main() {
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{"message": "pong"})
})
r.Run(":8080")
}CRUD Routes
type Book struct {
ID string `json:"id"`
Title string `json:"title"`
}
var books []Book
r.GET("/books", func(c *gin.Context) {
c.JSON(200, books)
})
r.POST("/books", func(c *gin.Context) {
var b Book
if err := c.BindJSON(&b); err != nil { return }
books = append(books, b)
c.JSON(201, b)
})
r.GET("/books/:id", func(c *gin.Context) {
id := c.Param("id")
// find and return
})Build a full REST API for a todo list using Gin.
Database with GORM
Setup
go get gorm.io/gorm go get gorm.io/driver/postgres
Models
type Product struct {
gorm.Model
Code string
Price uint
}
dsn := "host=localhost user=postgres dbname=shop sslmode=disable"
db, _ := gorm.Open(postgres.Open(dsn), &gorm.Config{})
db.AutoMigrate(&Product{})CRUD Operations
// Create
db.Create(&Product{Code: "P1", Price: 100})
// Read
var p Product
db.First(&p, 1)
db.Where("code = ?", "P1").First(&p)
// Update
db.Model(&p).Update("Price", 200)
// Delete
db.Delete(&p, 1)Relationships
type User struct {
gorm.Model
Name string
Orders []Order // has many
}
type Order struct {
gorm.Model
UserID uint
Total float64
}
db.Preload("Orders").Find(&users)Build a blog API with User-Post-Comment relationships using GORM.
Microservices in Go
Why Microservices?
Microservices split a large app into small, independently deployable services that communicate via HTTP/gRPC. Go's small binaries and concurrency model make it ideal.
gRPC Service
// users.proto
service UserService {
rpc GetUser(UserRequest) returns (UserResponse);
}
message UserRequest { int32 id = 1; }
message UserResponse { string name = 1; }// server.go
func (s *server) GetUser(ctx context.Context, req *pb.UserRequest) (*pb.UserResponse, error) {
return &pb.UserResponse{Name: "Ahmed"}, nil
}
func main() {
lis, _ := net.Listen("tcp", ":50051")
s := grpc.NewServer()
pb.RegisterUserServiceServer(s, &server{})
s.Serve(lis)
}Docker Deployment
# Dockerfile FROM golang:1.22-alpine AS build WORKDIR /app COPY . . RUN go build -o myapp FROM alpine COPY --from=build /app/myapp /myapp ENTRYPOINT ["/myapp"]
Service Discovery
Use Consul, etcd, or Kubernetes DNS for services to find each other dynamically.
Build 2 microservices (auth + products) that communicate via gRPC. Containerize with Docker.
Capstone: Concurrent CLI Tool
Build a tool that fetches data from multiple APIs concurrently using goroutines and channels.
// Requirements: // 1. Use net/http to fetch data // 2. Use goroutines for parallel requests // 3. Collect results via channels // 4. Use context for timeouts