How to Hash Data with SHA-256 in Go

Use the `crypto/sha256` package from the standard library to compute hashes, either by creating a hash object and writing data to it or by calling the convenient `Sum256` function for simple byte slices.

The checksum problem

You download a 4 GB Linux ISO. The website lists a long string of letters and numbers next to it. You run a quick command, get a matching string, and know the file arrived intact. That string is a cryptographic hash. In Go, computing it takes three lines of code, but understanding why those three lines work requires looking at how the language handles memory, interfaces, and binary data.

What a hash actually does

A hash function takes an input of any size and produces a fixed-size output. SHA-256 always returns exactly 32 bytes. Those 32 bytes look random, but they are deterministic. Change a single bit in the input and the output changes completely. Go treats binary data as []byte. The crypto/sha256 package lives in the standard library and implements the algorithm in optimized assembly and C code, wrapped in a clean Go API. You never write the math yourself. You just feed it data and collect the digest.

The standard library splits hashing into two patterns. One pattern works for data that already lives in memory. The other pattern works for data that arrives in chunks. Both patterns share the same underlying algorithm. They only differ in how they manage memory allocation.

The quick path: hashing in memory

Here is the simplest way to hash a string or a small byte slice: pass the data to sha256.Sum256 and convert the result to hexadecimal.

package main

import (
	"crypto/sha256"
	"encoding/hex"
	"fmt"
)

func main() {
	// Convert string to bytes because the hash function expects a byte slice
	data := []byte("Hello, Go!")
	
	// Compute the hash in one call. Returns a fixed-size [32]byte array.
	hash := sha256.Sum256(data)
	
	// Slice the array to a []byte so hex.EncodeToString can accept it
	hexString := hex.EncodeToString(hash[:])
	
	fmt.Println(hexString)
}

The compiler rejects the program with cannot use hash (variable of array type [32]byte) as []byte value in argument if you forget the [:] slice operation. Arrays and slices are different types in Go. Sum256 returns an array because the size is known at compile time. The hex package expects a slice because it needs to iterate over a length that can vary. Slicing an array is a zero-allocation operation. It just creates a new header pointing to the same underlying memory.

Under the hood, Sum256 allocates a temporary buffer, runs the SHA-256 compression function over your data, and copies the 32-byte result into the returned array. This is fast for payloads under a few megabytes. It becomes dangerous when you try to hash large files. Loading a 10 GB log file into a []byte just to hash it will exhaust your heap and trigger an out-of-memory panic.

Hash small payloads in memory. Let the compiler optimize the array copy.

The streaming path: hashing without loading everything

Files and network streams don't fit in memory. Go solves this with the io.Reader interface. The sha256.New() function returns a hash.Hash interface that also implements io.Writer. You pipe data through it using io.Copy. The hash object reads chunks, updates its internal state, and discards the chunk. Memory usage stays flat regardless of file size.

package main

import (
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"os"
)

// hashFile reads a file in chunks and returns its SHA-256 digest.
func hashFile(path string) (string, error) {
	// Open the file for reading. Returns an *os.File which implements io.Reader.
	file, err := os.Open(path)
	if err != nil {
		return "", err
	}
	// Ensure the file descriptor is released even if Copy panics or errors
	defer file.Close()

	// Create a new SHA-256 state machine. Implements io.Writer and hash.Hash.
	h := sha256.New()
	
	// Copy reads from file in 32KB chunks and writes them into the hash state.
	// Returns bytes copied and any read/write error.
	if _, err := io.Copy(h, file); err != nil {
		return "", err
	}

	// Finalize the hash. Passing nil appends to a nil slice, returning a new []byte.
	return hex.EncodeToString(h.Sum(nil)), nil
}

func main() {
	digest, err := hashFile("data.txt")
	if err != nil {
		fmt.Println("failed:", err)
		return
	}
	fmt.Println(digest)
}

The io.Copy function allocates a single 32 KB buffer and reuses it for the entire operation. It reads from the io.Reader, writes to the io.Writer, and repeats until EOF. The hash.Hash interface defines a Write method that updates the internal compression state without storing the input data. When io.Copy returns, the hash object holds exactly 32 bytes of state plus a few hundred bytes of padding. The original file data is gone.

The Sum(nil) call finalizes the computation. The nil argument tells the method to allocate a fresh slice for the result. If you pass an existing slice, Sum appends the digest to it instead. This design lets you hash multiple values sequentially and collect them in one buffer, though that pattern is rare in everyday code.

The if err != nil { return err } pattern appears twice here. Go makes error handling explicit. The compiler will not let you ignore the error return from os.Open or io.Copy. You either handle it or the program won't compile. This verbosity is intentional. It forces you to decide what happens when the disk is full or the file disappears mid-read.

Stream large payloads through io.Copy. Memory stays flat. The hash state is all you need.

Where things go wrong

Developers new to Go usually hit three stumbling blocks with hashing. The first is confusing arrays with slices. sha256.Sum256 returns [32]byte. The hex.EncodeToString function expects []byte. Forgetting the slice operation triggers cannot use hash (variable of array type [32]byte) as []byte value in argument. The fix is always hash[:].

The second mistake is passing a pointer to a string. Go strings are already cheap to pass by value. They are just a pointer to read-only memory plus a length. Writing func hash(s *string) adds an unnecessary indirection and breaks the standard library's expectations. Pass string or []byte directly.

The third mistake is misusing base64 instead of hex. Both encode binary data into ASCII. Hex produces 64 characters for a 32-byte digest. Base64 produces 44 characters. Both are valid. Pick one and stick to it across your codebase. The standard library provides both. There is no performance reason to prefer one over the other for hashing. Consistency matters more.

Runtime panics usually come from ignoring the io.Reader contract. If you wrap a file in a custom reader that returns io.EOF before actually reading all the data, io.Copy stops early and your hash will be wrong. The compiler won't catch this. You need to test with known fixtures and verify the output matches a reference implementation.

The worst hash bug is the one that silently produces the wrong digest. Always compare against a known good value during development.

Picking the right approach

Use sha256.Sum256 when you have a complete byte slice already in memory and the payload is under a few megabytes. Use sha256.New() with io.Copy when you are reading from a file, network connection, or any io.Reader that streams data. Use a custom io.Reader wrapper when you need to transform or filter data before it reaches the hash state. Use sequential hashing with Sum(existingSlice) when you are building a compound digest from multiple independent sources and want to avoid repeated allocations.

Pick the pattern that matches your data source. The algorithm stays the same. Only the memory management changes.

Where to go next