How to Use the io.ReadWriter and io.ReadCloser Interfaces

Use io.ReadWriter for bidirectional streams and io.ReadCloser for readable resources requiring explicit closure, often wrapped with bufio.NewReader for efficiency.

The stream is the shape

You are building a proxy server. A client connects, sends a line of text, and expects the server to echo it back. You grab the connection, read the line, and write it back. Later, you realize you need to handle files too. Files also let you read and close, but they don't let you write back to the same handle in the same way. You start writing separate functions for sockets, files, and pipes. The code duplicates.

Go offers a way to treat all these different things as the same shape: interfaces. io.ReadWriter and io.ReadCloser are two of the most common shapes you will encounter. They let you write functions that work with any stream, not just one specific type.

Interfaces define behavior, not structure

An interface in Go describes a set of methods. If a type has those methods, it satisfies the interface. No declarations needed. No implements keyword. The compiler checks the method signatures and allows the assignment.

io.Reader asks for one method: Read(p []byte) (n int, err error). io.Writer asks for Write(p []byte) (n int, err error). io.ReadWriter combines them. It says "I can read and I can write." io.ReadCloser combines Reader with Closer. Closer asks for Close() error. It says "I can read, and when you are done, I need you to shut me down."

Think of a library book. You can read it. That's a Reader. You can't write in it. If you get a notebook, you can write in it. That's a Writer. A whiteboard is a ReadWriter. You can read what's on it and write new things. A ReadCloser is like a rented car. You can drive it, but when you return it, you have to hand back the keys and lock it. If you forget to close, the resource stays allocated, and eventually, you run out of file descriptors or connections.

Minimal example: buffering a bidirectional stream

Here is a function that accepts any io.ReadWriter. It buffers the input for efficiency, reads a line, and writes it back.

package main

import (
	"bufio"
	"fmt"
	"io"
)

// ProcessStream reads a line from rw and writes it back.
// It accepts any type that implements io.ReadWriter.
func ProcessStream(rw io.ReadWriter) error {
	// Wrap with bufio to reduce system calls on small reads.
	// Raw reads can be expensive if the underlying stream is a network socket.
	buf := bufio.NewReader(rw)

	// ReadBytes blocks until it finds the delimiter or an error.
	// It handles partial reads internally, so you get a complete line.
	data, err := buf.ReadBytes('\n')
	if err != nil && err != io.EOF {
		return err
	}

	// Write the data back to the underlying stream.
	// Write can return a short write, but for small data it usually succeeds fully.
	_, err = rw.Write(data)
	return err
}

func main() {
	// bytes.Buffer implements io.ReadWriter.
	// This is safe for testing without a real network connection.
	var buf bytes.Buffer
	buf.WriteString("hello world\n")

	// Seek to the beginning so we can read what we wrote.
	buf.Seek(0, 0)

	err := ProcessStream(&buf)
	if err != nil {
		fmt.Println(err)
	}
}

The function takes io.ReadWriter. It does not care if the argument is a *net.TCPConn, a *bytes.Buffer, or a custom type. As long as the type has Read and Write methods with the right signatures, the call compiles. This follows the Go convention: accept interfaces, return structs. The function accepts an interface to be flexible. It returns an error, which is also an interface, but the concrete error value is a struct or string.

Goroutines are cheap. Channels are not magic. If you need to run this processing in the background, spawn a goroutine and pass the stream. Just remember that sharing a stream between goroutines requires synchronization. io.ReadWriter is not thread-safe by default.

How the Read contract works

The Read method has a subtle contract. It returns the number of bytes read and an error. The number of bytes can be less than the buffer size. The error can be nil. This is a short read. The caller must check n. If n > 0, data is available. If err == io.EOF, the stream ended. io.EOF can accompany a short read.

This design supports non-blocking I/O and pipes. A network socket might have only 10 bytes available when you ask for 1024. Read returns 10 and nil. The caller loops until the buffer is full or an error occurs. bufio handles this complexity for you. ReadBytes loops internally until it finds the delimiter. Use bufio unless you have a specific reason to manage buffers manually.

The compiler checks interface satisfaction at compile time. If you pass a string to a function expecting io.Reader, the compiler rejects the program with cannot use s (variable of type string) as io.Reader value in argument. Strings are values, not streams. You cannot read from a string repeatedly. Use strings.NewReader(s) to wrap a string in a reader.

Buffering reduces system calls. Wrap early.

Realistic example: reading and closing a file

Real resources like files and network connections hold operating system handles. You must release them. io.ReadCloser enforces this contract.

Here is a function that opens a file, reads the first line, and ensures the file is closed.

package main

import (
	"bufio"
	"fmt"
	"io"
	"os"
)

// ReadFirstLine opens a file, reads the first line, and ensures the file is closed.
// It returns the line and any error encountered.
func ReadFirstLine(filename string) (string, error) {
	// Open returns a *os.File, which implements io.ReadCloser.
	// os.Open opens the file for reading only.
	f, err := os.Open(filename)
	if err != nil {
		return "", err
	}

	// Defer Close to run when the function returns.
	// This prevents file descriptor leaks even if an error occurs later.
	// The convention is to defer close immediately after checking the open error.
	defer f.Close()

	// Buffer the reader for performance.
	// Reading byte-by-byte from disk is extremely slow.
	buf := bufio.NewReader(f)

	// Read until newline or EOF.
	// ReadString returns the data including the delimiter.
	line, err := buf.ReadString('\n')
	if err != nil && err != io.EOF {
		return "", err
	}

	return line, nil
}

func main() {
	line, err := ReadFirstLine("config.txt")
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(line)
}

The defer f.Close() call is critical. Without it, the file descriptor remains open. If you call this function in a loop, the program will eventually crash with "too many open files". The compiler cannot enforce Close. You must use defer or a finally-like pattern. defer is the Go way.

The receiver name in os.File methods is usually one letter. (f *File) Read(...) not (this *File). This is a community convention. Keep receiver names short and consistent with the type.

Defer close. Always defer close.

Pitfalls and common errors

The compiler catches interface mismatches immediately. If you try to pass a type that lacks a required method, you get a clear error. If you pass a *os.File opened with os.Open to a function expecting io.ReadWriter, the compiler rejects the program with cannot use f (variable of type *os.File) as io.ReadWriter value in argument. The file is read-only. It satisfies io.ReadCloser, but not io.ReadWriter.

Runtime errors are trickier. Forgetting Close on a ReadCloser is a silent bug. The program might work for a while, then crash. The compiler cannot help here. You must audit your code for defer statements.

Another pitfall is io.EOF. ReadBytes returns io.EOF if there is no data. You must check for io.EOF and decide if it is an error or expected. An empty file returns io.EOF immediately. That is valid. A network connection that drops returns io.EOF. That might be an error. Context matters.

Sometimes an API demands a ReadCloser, but you only have a Reader. io.NopCloser bridges the gap. It wraps a Reader and returns a ReadCloser where Close does nothing. This is safe when the underlying resource does not need closing, like a string or a buffer.

Here is how to use io.NopCloser to satisfy an interface.

package main

import (
	"fmt"
	"io"
	"strings"
)

// ConsumeCloser expects a ReadCloser.
// It defers Close to follow the contract.
func ConsumeCloser(rc io.ReadCloser) error {
	defer rc.Close()

	// Read all data.
	// io.ReadAll handles the Read loop and short reads.
	data, err := io.ReadAll(rc)
	if err != nil {
		return err
	}

	fmt.Printf("Read %d bytes\n", len(data))
	return nil
}

func main() {
	// strings.NewReader returns an io.Reader, not io.ReadCloser.
	reader := strings.NewReader("data")

	// Wrap with NopCloser to satisfy the interface.
	// Close is a no-op, so resources are safe.
	closer := io.NopCloser(reader)

	err := ConsumeCloser(closer)
	if err != nil {
		fmt.Println(err)
	}
}

Functions that take a context should respect cancellation. If your function reads from a stream, it should accept context.Context as the first parameter. Convention dictates the parameter is named ctx. If the context is cancelled, the function should stop reading and return. This prevents goroutine leaks when the caller gives up.

The compiler checks types. You check resources.

Decision matrix

Use io.Reader when you only need to consume data. This is the most general interface. Almost everything in Go implements io.Reader.

Use io.Writer when you only need to produce data. Logs, responses, and files often use this.

Use io.ReadWriter when the stream supports bidirectional flow. TCP connections, pipes, and in-memory buffers fit this shape.

Use io.ReadCloser when you hold a resource that must be released. Files, HTTP responses, and compressed streams require closing.

Use io.ReadWriteCloser when you have a bidirectional stream that also needs cleanup. Network connections usually implement this full set.

Use strings.NewReader when you have a string and need to pass it to a function expecting an io.Reader.

Use bytes.Buffer when you need a io.ReadWriter backed by memory for testing or temporary storage.

Use io.NopCloser when you have a Reader but an API demands a ReadCloser.

Use io.Pipe when you need to connect a writer to a reader across goroutines. The pipe blocks until both sides are ready.

Pick the smallest interface that does the job. Trust gofmt. Argue logic, not formatting.

Where to go next