• English
  • Example: io.ReadSeeker and io.WriteSeeker

    Sometimes a stream needs more than sequential access.

    You may need to read a header, jump to another position, then continue reading. Or write data first and later seek back to fill in a value whose final size was not known yet.

    That is what io.ReadSeeker and io.WriteSeeker provide:

    seek → read/write → seek → read/write

    The important property is that Seek changes the stream's current position.

    If each operation already knows its offset, io.ReaderAt or io.WriterAt is usually a better fit.

    Seek moves the current position; At uses an explicit offset.

    Quick Example

    Read three bytes starting at position 5:

    package main
    
    import (
    	"fmt"
    	"io"
    	"strings"
    )
    
    func main() {
    	r := strings.NewReader("0123456789")
    
    	if _, err := r.Seek(5, io.SeekStart); err != nil {
    		panic(err)
    	}
    
    	buf := make([]byte, 3)
    	if _, err := io.ReadFull(r, buf); err != nil {
    		panic(err)
    	}
    
    	fmt.Println(string(buf))
    }

    Output:

    567

    After the Seek, the reader's current position is 5.

    The subsequent Read starts there and advances the position to 8.

    io.ReadSeeker

    Use io.ReadSeeker when a function needs to move around the same readable stream.

    For example, a parser may need to return to the beginning and read a fixed header:

    func readHeader(r io.ReadSeeker) ([]byte, error) {
    	if _, err := r.Seek(0, io.SeekStart); err != nil {
    		return nil, err
    	}
    
    	var header [16]byte
    
    	if _, err := io.ReadFull(r, header[:]); err != nil {
    		return nil, err
    	}
    
    	return header[:], nil
    }

    The function explicitly changes the caller's current position.

    That is an important part of the API contract.

    A function accepting io.ReadSeeker is not merely saying:

    “I need random access.”

    It is saying:

    “I need to control the stream's current position.”

    io.WriteSeeker

    io.WriteSeeker is useful when the final value of something is not known when it is first written.

    A common pattern is:

    write placeholder
          ↓
    write payload
          ↓
    seek back
          ↓
    overwrite placeholder

    For example, write a four-byte payload length before the payload:

    package example
    
    import (
    	"encoding/binary"
    	"fmt"
    	"io"
    )
    
    func writeRecord(w io.WriteSeeker, payload []byte) error {
    	var length [4]byte
    
    	// Reserve space for the length.
    	if _, err := w.Write(length[:]); err != nil {
    		return fmt.Errorf("write length placeholder: %w", err)
    	}
    
    	if _, err := w.Write(payload); err != nil {
    		return fmt.Errorf("write payload: %w", err)
    	}
    
    	// Return to the placeholder.
    	if _, err := w.Seek(0, io.SeekStart); err != nil {
    		return fmt.Errorf("seek to length: %w", err)
    	}
    
    	binary.BigEndian.PutUint32(length[:], uint32(len(payload)))
    
    	if _, err := w.Write(length[:]); err != nil {
    		return fmt.Errorf("write length: %w", err)
    	}
    
    	return nil
    }

    The key idea is not the length encoding.

    It is the ability to patch previously written bytes by moving the write position.

    This pattern is common with file formats and other seekable output where metadata depends on data written later.

    Seek Is Stateful

    Consider:

    r.Seek(100, io.SeekStart)

    The reader now has a new current position.

    A later operation using the same reader depends on that state:

    r.Read(buf)

    starts at the position established by the previous Seek.

    That makes a seeker convenient for sequential parsing with occasional jumps, but it also makes it stateful.

    ReaderAt Is Different

    If the operation is simply:

    “Give me bytes at offset 1024.”

    then ReaderAt expresses that requirement directly:

    n, err := r.ReadAt(buf, 1024)

    It does not first move a shared cursor to 1024.

    Compare:

    // Stateful cursor.
    if _, err := r.Seek(1024, io.SeekStart); err != nil {
    	return err
    }
    
    _, err := r.Read(buf)

    with:

    // Explicit offset.
    _, err := r.ReadAt(buf, 1024)

    The second form is often easier to reason about when operations are independent.

    Why This Matters for Concurrent Access

    A seeker carries mutable position state.

    This can be problematic if multiple operations share it:

    Goroutine A                  Goroutine B
    
    Seek(100)                    Seek(200)
    Read(...)                    Read(...)
         │                            │
         └────── shared cursor ───────┘

    The operations are both modifying the same position.

    Without synchronization, one operation can move the cursor while another operation expects it to remain somewhere else.

    For independent random-access operations, prefer:

    io.ReaderAt
    io.WriterAt

    or give each operation its own seeker.

    Do not assume that an implementation being backed by a file makes arbitrary concurrent Seek + Read operations safe at the interface level.

    Common Mistakes

    1. Assuming Read Fills the Buffer

    Read may return fewer bytes than requested.

    If the format requires exactly the requested number:

    if _, err := io.ReadFull(r, buf); err != nil {
    	return err
    }

    Do not confuse:

    r.Read(buf)

    with:

    io.ReadFull(r, buf)

    ReadSeeker provides seeking, not exact-read semantics.

    2. Using Seek When You Really Need ReadAt

    If each operation has an independent offset:

    n, err := r.ReadAt(buf, offset)

    is usually clearer than:

    if _, err := r.Seek(offset, io.SeekStart); err != nil {
    	return err
    }
    
    n, err := r.Read(buf)

    The first expresses the operation directly and does not modify the current seek position.

    3. Forgetting That Seek Changes Caller State

    A function like:

    func parseHeader(r io.ReadSeeker) error

    may leave r at a different position when it returns.

    If the caller expects a particular position afterward, that contract needs to be explicit.

    Sometimes the function should save and restore the original position:

    pos, err := r.Seek(0, io.SeekCurrent)
    if err != nil {
    	return err
    }
    
    defer func() {
    	_, _ = r.Seek(pos, io.SeekStart)
    }()

    Whether restoring the position is appropriate depends on the API contract. Do not silently impose cursor behavior on callers.

    4. Sharing a Seeker Across Concurrent Operations

    This is unsafe as a general interface-level assumption:

    go readAtPosition(r, 100)
    go readAtPosition(r, 200)

    if both functions use Seek followed by Read.

    Use ReaderAt for independent offset-based reads, or synchronize access to the seeker.

    5. Assuming Every Seeker Supports Arbitrary Positions

    io.Seeker defines the operation, not the storage model.

    Whether negative offsets, extending writes, seeking beyond the current end, or particular Seek modes are valid depends on the concrete implementation.

    Check and handle the returned error.

    ReadSeeker vs. ReaderAt

    The distinction is easiest to remember as cursor vs. offset:

    APIPosition modelChanges current position?
    io.ReaderSequentialYes, through reads
    io.ReadSeekerMutable cursorYes
    io.ReaderAtExplicit offsetNo
    io.SectionReaderBounded view over ReaderAtUses explicit offsets internally

    For example:

    // Move the cursor.
    r.Seek(offset, io.SeekStart)
    r.Read(buf)

    versus:

    // Read at an explicit offset.
    r.ReadAt(buf, offset)

    The second is usually the better abstraction when reads are independent.

    WriteSeeker vs. WriterAt

    The same distinction applies to writing.

    Use WriteSeeker when the workflow is naturally:

    write → seek → write

    Use WriterAt when the operation already knows the destination offset:

    n, err := w.WriteAt(data, offset)

    This is particularly useful when independent writes should not depend on shared cursor state.

    API Selection

    RequirementPreferWhy
    Sequential reading/writingio.Reader / io.WriterNo seeking required
    Move a shared cursor and continueio.ReadSeeker / io.WriteSeekerStateful position control
    Read at independent offsetsio.ReaderAtExplicit offset
    Write at independent offsetsio.WriterAtExplicit offset
    Read a bounded regionio.SectionReaderFixed view over a ReaderAt
    Write relative to a fixed baseio.OffsetWriterMaps logical offsets to a base position

    Rule of Thumb

    Use ReadSeeker / WriteSeeker when your algorithm needs to move the current position.

    Use ReaderAt / WriterAt when your algorithm already knows the offset.

    The mental model is simple:

    Seek → move the cursor
    At   → use an explicit offset