Memory Management in Go: 4 Effective Approaches

January 13, 2025
Written by
Oluwadamilola Oshungboye
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Memory Management in Go: 4 Effective Approaches

Go, also known as Golang, is known for its effective memory management system. While the language's garbage collector automates most tasks involving memory, understanding and implementing effective memory management techniques helps improve your Go applications' performance and resource usage.

So, in this tutorial, you’ll learn four effective techniques you can apply to manage memory even better in your Go application. Whether you are an experienced developer or just starting out, you will gain insightful knowledge that you can apply to make your Go code more memory-efficient.

Let’s get started!

Prerequisites

Before going any further, ensure you have the following:

  • Go installed on your system. You can download it from the official website.
  • A text editor or IDE of your choice to write and edit your Go code
  • A project directory for the tutorial's code

Go’s garbage collector

Go's memory management is primarily handled by its garbage collector (GC). The GC automatically allocates and deallocates memory, relieving developers from manual memory management tasks common in languages like C or C++.

Here’s an example to demonstrate how the GC works by tracking memory usage before and after garbage collection.

In your project directory, create a new file named main.go, and paste the code below into that file.

package main

import (
	"fmt"
	"runtime"
)

func main() {
	var mem runtime.MemStats
	runtime.ReadMemStats(&mem)
	fmt.Printf("Initial memory usage: %v KB\n", mem.Alloc/1024)

	references := make([]*[1000000]int, 100)
	for i := 0; i < 100; i++ {
		references[i] = new([1000000]int)
	}
	runtime.ReadMemStats(&mem)
	fmt.Printf("Memory usage after allocation: %v KB\n", mem.Alloc/1024)
	references = nil
	runtime.GC()
	runtime.ReadMemStats(&mem)
	fmt.Printf("Memory usage after garbage collection: %v KB\n", mem.Alloc/1024)
}

In the code above, the initial memory usage is printed, followed by the allocation of 100 large arrays, which causes a significant increase in memory usage. The references slice is then set to nil to remove all references to these arrays, and a manual garbage collection is triggered. Finally, the memory usage is printed again, to show the current memory usage.

Run the below command in your terminal to execute the code.

go run main.go

You should see a result similar to the image below indicating that the GC cleared the unused memory.

Terminal output displaying memory usage in a Go program before and after allocation and garbage collection.

Memory management approaches in Go

In this section, you’ll learn about the approaches and techniques you can use to manage memory better in your Go application.

1. Design memory-efficient structs

In Go, structs are typed collections of fields. Each field in the struct can be of a different data type, making structs a flexible way of combining related data. Structs are commonly used to represent records or complex data structures.

While structs can be considered, in some respects, as being similar to classes in object-oriented languages such as Java and C++, Go does not support explicit inheritance as other languages do.

Here’s how you would define a struct in Go.

type StructName struct {
    field1 Type1
    field2 Type2
}

When using structs in Go, it's important to consider how the fields are aligned in memory. You should order struct fields from largest to smallest to minimise the amount of padding between fields.

Padding refers to the extra space added between struct fields to align them properly in memory. This alignment is necessary for the CPU to access the fields efficiently, but it can lead to wasted memory if fields are not ordered carefully. By organising fields from largest to smallest, you can reduce the amount of padding, making the memory layout more compact.

Replace the content of the main.go file with the below code block to demonstrate this.

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	type Inefficient struct {
		a bool
		b int64
		c bool
		d int32
	}

	type Efficient struct {
		b int64
		d int32
		a bool
		c bool
	}

	fmt.Printf("Size of Inefficient: %d bytes\n", unsafe.Sizeof(Inefficient{}))
	fmt.Printf("Size of Efficient: %d bytes\n", unsafe.Sizeof(Efficient{}))
}

The code above defines two structs: Inefficient and Efficient. The Inefficient struct arranges its fields in a way that increases memory usage due to extra padding between fields.

On the other hand, the Efficient struct organises fields from largest to smallest, reducing padding and making the struct more memory-efficient. The difference in size is shown using the unsafe.Sizeof() function.

Run the command below in your terminal to execute the code.

go run main.go

You will see the result displayed below.

Terminal output of Go code showing Size of Inefficient

As shown in the image above, structuring your structs from largest to smallest fields causes it to use memory more effectively.

2. Use pointers for large data structures

Pointers in Go are variables that store the memory address of another variable. They allow you to reference and manipulate the value of a variable directly in memory rather than working with a copy of the value.

Key facts about pointers:

  • Declaration: A pointer is declared by placing an asterisk (*) before the type. For example, var p *int declares a pointer to an integer.
  • Address Operator (&): To get the address of a variable, you use the address operator. For example, p = &x assigns the address of x to the pointer p.
  • Dereferencing: To access or modify the value at the address stored in a pointer, you use the dereference operator (*). For instance, *p = 10 sets the value of x to 10 if p points to x.

Here’s a simple example to demonstrate pointers. Replace the contents of your main.go file with the below code block.

package main

import (
    "fmt"
)

func main() {
    number := 42
    fmt.Println("Initial value of number:", number)
    pointerToNumber := &number
    *pointerToNumber = 99
    fmt.Println("Value of number after pointer modification:", number)
    fmt.Printf("Memory address of number: %p\n", pointerToNumber)
}

In the code above, the variable number is initialised to 42. A pointer named pointerToNumber is created and assigned the memory address of number. The value of number is then updated to 99 by modifying the value stored directly by the pointerToNumber pointer.

Run the below command in your terminal to execute the code.

go run main.go

You should see the result shown in the image below.

Terminal output showing initial and modified values of a number and its memory address in a Go program.

Using pointers is an effective way to save memory when working with large data structures. Instead of passing these data structures directly to a function, you can pass a pointer that holds their memory address. This allows the function to modify the original data without the overhead of creating a copy, which normally occurs when the value of an object is passed into a function.

However, you must be careful not to create references that can lead to memory leaks by setting the references to nil after the code is done. Replace the content of your main.go file with the code block below.

package main

import (
	"fmt"
	"runtime"
)

type LargeDataSet struct {
	values [8000000]int
}

func GenerateData() *LargeDataSet {
	data := &LargeDataSet{}
	for index := range data.values {
		data.values[index] = index % 1000
	}
	return data
}

func main() {
	fmt.Println("Memory usage before data generation:")
	DisplayMemoryStats()
	dataset := GenerateData()
	fmt.Println("\nMemory usage after data generation:")
	DisplayMemoryStats()
	fmt.Printf("\nFirst value in dataset: %d\n", dataset.values[0])
	dataset = nil
	runtime.GC()
	fmt.Print("\nMemory usage after removing dataset reference: ")
	DisplayMemoryStats()
}

func DisplayMemoryStats() {
	var memStats runtime.MemStats
	runtime.ReadMemStats(&memStats)
	allocatedMiB := memStats.Alloc / 1024 / 1024
	fmt.Printf("Allocated = %v MiB", allocatedMiB)
}

In the example above, the GenerateData() function creates a large data structure and returns a pointer to it, avoiding the overhead of copying the large data. After executing the function, the pointer is set to nil, allowing the garbage collector to reclaim the memory.

Run the below command in your terminal to execute the code.

go run main.go

You should see the result shown in the image below.

Screenshot of terminal showing memory allocation before and after data generation in a Go program.

3. Manage variables scopes

The proper understanding and usage of variable scopes plays a vital role in memory management in Go. A variable's scope determines its visibility and lifetime within the program, impacting directly when the garbage collector can free up the memory associated with it.

Limit variables scopes

While writing your Go code, you should ensure you keep variable scopes as limited as possible, to ensure that the garbage collector can free them as soon as possible.

To demonstrate this, replace the content of your main.go file with the below code block.

package main

import (
	"fmt"
)

func main() {
	message := "This is available throughout main"
	fmt.Println(message)
	{
		blockMessage := "This is only available in this block"
		fmt.Println(blockMessage)
		blockMessage = ""
	}
	fmt.Println(blockMessage)
}

In the example above, the message variable is declared at the top of the main() function and is accessible throughout the entire function, including all nested blocks. Conversely, the blockMessage variable is defined within a nested block inside the main() function. Consequently, its scope is limited to that block, making it inaccessible outside of it.

Therefore, accessing the blockMessage variable outside its scope will result in a compilation error, as shown in the image below. This is because the Go compiler detects that the variable is not defined in that context.

Screenshot of a terminal showing an error when running a Go program, mentioning undefined

Use global variables cautiously

You can also manage memory more effectively by using global variables cautiously since they persist throughout the program's entire lifecycle and can result in memory leaks.

Replace the content of your main.go file with the below code block.

package main

import (
	"fmt"
	"runtime"
)

var globalStorage *Storage

type Storage struct {
	Elements [800000]int32
}

func initializeStorage() {
	var tempStorage Storage
	for i := 0; i < len(tempStorage.Elements); i++ {
		tempStorage.Elements[i] = int32(i % 1000)
	}
	globalStorage = &tempStorage
}

func main() {
	fmt.Println("Memory usage before initializeStorage:")
	displayMemoryStats()
	initializeStorage()
	fmt.Println("\nMemory usage after initializeStorage:")
	displayMemoryStats()
	fmt.Println("\nMemory usage after local variable out of scope (globalStorage still set):")
	displayMemoryStats()
}

func displayMemoryStats() {
	var memStats runtime.MemStats
	runtime.ReadMemStats(&memStats)
	allocatedMiB := memStats.Alloc / 1024 / 1024
	fmt.Printf("Allocated = %v MiB", allocatedMiB)
}

In the code block above, the initializeStorage() function creates a tempStorage variable and fills its Elements array with data. Then, the address of tempStorage is assigned to the global variable globalStorage.

Run the command below in your terminal to execute the code.

go run main.go

You will see the below results displayed in your terminal.

Terminal screenshot showing memory usage at different stages in a Go program.

As seen in the image above, although tempStorage goes out of scope when initializeStorage finishes, the global variable globalStorage continues to hold a reference to it because it was defined globally. This prevents the garbage collector from reclaiming the memory.

4. Pool resources

Go has a technique for managing memory resource-intensive scenarios. It allows you to reuse resources e.g., objects, and memory instead of allocating and deallocating them repeatedly.

You should use sync.Pool when dealing with scenarios that require frequent reuse of short-lived objects to reduce allocation costs and garbage collection overhead, especially during concurrency operations.

To demonstrate this, replace the content of your main.go file with the code block below.

package main

import (
	"fmt"
	"sync"
)

var syncPool = sync.Pool{
	New: func() interface{} {
		return &syncStruct{}
	},
}

type syncStruct struct {
	Object string
}

func main() {
	data := syncPool.Get().(*syncStruct)
	data.Object = "Pooling resources!"
	fmt.Println(data.Object)
	syncPool.Put(data)
	data2 := syncPool.Get().(*syncStruct)
	fmt.Println(data2.Object)
}

In the example above, sync.Pool is used to manage and reuse instances of the syncStruct type. When an object is requested from the pool using syncPool.Get(), if none are available, a new one is created. The object is then used, and after processing it is returned to the pool using syncPool.Put().

Run the command below in your terminal to run the code.

go run main.go

You will see the results displayed below.

Terminal screenshot showing go run main.go command with output Pooling resources! repeated twice.

Profile and monitor memory usage

Go provides in-built methods for identifying bottlenecks and memory leaks in your Go application using its profiler: pprof.

To profile memory usage, you will need to import the runtime/pprof package and write memory profiles to files for later analysis. Running these profiles through pprof helps identify inefficient memory usage in your application.

Below is an example of its usage, replace the contents of your main.go file with the below code block.

package main

import (
	"log"
	"os"
	"runtime"
	"runtime/pprof"
)

func main() {
	runtime.MemProfileRate = 2048
	profileFile, err := os.Create("mem.prof")
	if err != nil {
		log.Fatal("Failed to create memory profile file: ", err)
	}
	defer profileFile.Close()
	generateDataStructures()
	if err := pprof.WriteHeapProfile(profileFile); err != nil {
		log.Fatal("Unable to write memory profile: ", err)
	}
	log.Println("Memory profile saved to mem.prof")
}

func generateDataStructures() []map[int]string {
	structures := make([]map[int]string, 800000)
	for i := range structures {
		structures[i] = map[int]string{
			0: "A", 1: "B", 2: "C", 3: "D", 4: "E",
		}
	}
	return structures
}

In the code above, Go’s runtime function runtime.MemProfileRate() is set to capture memory allocations in 2KB intervals. The program creates a profile file named mem.prof to store the profiling data. Finally, the profiling data is written to the mem.prof file for later analysis.

Run the code using the command below

go run main.go

You should see a prompt that indicates the profile data has been saved as shown in the image below.

Terminal window showing a Go program run with memory profile saved to mem.prof

Next, run the code below in your terminal to begin the analysis of the profile data using pprof.

go tool pprof mem.prof

This will return the prompt as seen in the image below.

Screenshot of a terminal window using Go's pprof tool to analyze memory profile at 1

Explore the profile data with the top command, which displays the top memory-consuming functions.

Paste the below command in your terminal to see this command in action.

top
Profiling report showing memory usage distribution in a Go application with 'main.generateDataStructures'.

The top memory-consuming functions are displayed as seen in the image above.

It’s also possible to check the particular lines in each function that consumes the memory using the list <function_name> command. Use the command below to see this in action.

list generateDataStructures
Black terminal screen showing memory usage of generateDataStructures function in a Go program.

As seen in the image above, the memory allocation per line is displayed. It shows that line 32 consumes the most memory — 64.15 MB.

That’s how to improve memory management in Go

In this article, you learned about Go's garbage collector and the best approaches for improved memory efficiency. You also learned how to use Go's built-in memory profiler to detect memory leaks in your code.

Effective memory management enhances application performance and reduces the risk of memory-related errors. By applying the techniques in this tutorial, you can confidently build memory-safe Go applications.

Cheers to more learning!

Oluwadamilola Oshungboye is a Software Engineer and Technical Writer who loves writing about Cloud and Backend Technologies. He can be reached on X (formerly known as Twitter).