Many file formats are not consumed strictly from beginning to end. An index may point to records at known offsets. A binary container may keep metadata in a header and payloads elsewhere. A server may need to serve several independent byte ranges from the same open file.

The obvious approach is to call Seek, then Read. That works when one goroutine owns the file position. It becomes fragile when several operations share the same file because the current offset is mutable state.

Go’s io.ReaderAt models a different operation: read these bytes from this explicit offset. The offset belongs to the call rather than to a shared cursor. That distinction makes random-access code easier to reason about and allows parallel reads when the underlying implementation supports the ReaderAt contract.

This article explains the contract, the EOF details that often surprise developers, when os.File.ReadAt is preferable to Seek plus Read, and when io.SectionReader gives a cleaner interface.

The problem is shared cursor state

An ordinary io.Reader exposes sequential reads. For a seekable file, Seek changes the offset used by later Read calls.

Conceptually:

Seek(4096)
   |
   v
shared file offset = 4096
   |
   v
Read(buffer)

That is straightforward in single-owner code. The danger appears when independent operations interleave.

Imagine two goroutines using one *os.File:

file.Seek(0, io.SeekStart)
file.Read(header)

and:

file.Seek(4096, io.SeekStart)
file.Read(record)

If those sequences overlap, one goroutine can change the file offset after the other goroutine seeks but before it reads. Protecting every Seek plus Read pair with a mutex can make the sequence correct, but now unrelated reads are serialized around shared mutable state.

ReadAt removes that cursor dependency from the operation.

ReaderAt makes the offset part of the read

The interface is small:

type ReaderAt interface {
    ReadAt(p []byte, off int64) (n int, err error)
}

The call asks for len(p) bytes starting at off.

For example:

package main

import (
    "fmt"
    "os"
)

func main() {
    file, err := os.Open("records.bin")
    if err != nil {
        panic(err)
    }
    defer file.Close()

    header := make([]byte, 8)
    n, err := file.ReadAt(header, 0)
    if err != nil {
        panic(err)
    }

    fmt.Printf("read %d bytes: % x\n", n, header)
}

*os.File implements ReadAt. The important property is that this read starts at byte offset 0 regardless of the file’s current seek position.

The io.ReaderAt contract says a positional read should neither affect nor be affected by an underlying seek offset. It also explicitly allows clients to execute parallel ReadAt calls on the same input source.

That is an API guarantee of ReaderAt, not a claim that every storage device makes parallel reads faster. Whether concurrency improves throughput depends on the filesystem, storage hardware, caching, request sizes, and workload.

ReadAt is stricter than Read about filling the buffer

Read and ReadAt differ in an important way.

A normal Read may return fewer bytes than the buffer can hold without that being an EOF condition. Callers that require an exact amount commonly use io.ReadFull.

ReadAt has a stricter contract: when it returns fewer than len(p) bytes, it returns a non-nil error explaining why the buffer could not be filled.

For an *os.File, reaching the end of the file before filling the buffer produces io.EOF.

Consider a five-byte file:

ABCDE

Reading four bytes from offset 1 succeeds:

buf := make([]byte, 4)
n, err := file.ReadAt(buf, 1)

// n == 4
// string(buf) == "BCDE"
// err is nil for os.File

Reading four bytes from offset 3 can return only two bytes:

buf := make([]byte, 4)
n, err := file.ReadAt(buf, 3)

// n == 2
// string(buf[:n]) == "DE"
// err == io.EOF for os.File

The partial data is still valid. Do not discard buf[:n] merely because err is non-nil.

Do not assume EOF is always nil after a full ReaderAt read

The general io.ReaderAt interface permits either nil or io.EOF when exactly len(p) bytes are returned and those bytes end exactly at the end of the source.

That means generic code should primarily use the byte count to decide whether the requested region was fully read.

A reusable helper can make that policy explicit:

package region

import (
    "fmt"
    "io"
)

func ReadExactlyAt(r io.ReaderAt, off int64, size int) ([]byte, error) {
    if off < 0 {
        return nil, fmt.Errorf("offset must be non-negative")
    }
    if size < 0 {
        return nil, fmt.Errorf("size must be non-negative")
    }

    buf := make([]byte, size)
    n, err := r.ReadAt(buf, off)
    if n != size {
        if err == nil {
            err = io.ErrUnexpectedEOF
        }
        return nil, fmt.Errorf("read region at %d: got %d of %d bytes: %w", off, n, size, err)
    }

    // A ReaderAt may report io.EOF together with a complete read at the
    // exact end of the source. The requested region is still complete.
    if err != nil && err != io.EOF {
        return nil, fmt.Errorf("read region at %d: %w", off, err)
    }

    return buf, nil
}

This helper distinguishes the property the caller actually needs—the complete requested region was returned—from the particular EOF convention of one implementation.

When you are calling (*os.File).ReadAt directly, its documentation is more specific: a non-nil error is guaranteed when fewer than the requested bytes are read, and EOF is reported at the end of the file.

Read independent regions concurrently without seeking

Suppose a format stores a 16-byte header and a 32-byte footer, and both can be parsed independently.

With ReadAt, the two reads do not need to coordinate a shared file position:

package main

import (
    "fmt"
    "os"
    "sync"
)

func main() {
    file, err := os.Open("archive.bin")
    if err != nil {
        panic(err)
    }
    defer file.Close()

    info, err := file.Stat()
    if err != nil {
        panic(err)
    }
    if info.Size() < 48 {
        panic("archive is too small")
    }

    header := make([]byte, 16)
    footer := make([]byte, 32)

    var wg sync.WaitGroup
    var headerErr error
    var footerErr error

    wg.Add(2)

    go func() {
        defer wg.Done()
        _, headerErr = file.ReadAt(header, 0)
    }()

    go func() {
        defer wg.Done()
        _, footerErr = file.ReadAt(footer, info.Size()-int64(len(footer)))
    }()

    wg.Wait()

    if headerErr != nil {
        panic(headerErr)
    }
    if footerErr != nil {
        panic(footerErr)
    }

    fmt.Printf("header: % x\n", header)
    fmt.Printf("footer: % x\n", footer)
}

The buffers are separate, the offsets are explicit, and each goroutine writes only its own error variable. There is no Seek call whose effects can interfere with the other read.

The file must remain open until all reads finish. Calling Close concurrently with active I/O introduces a different lifetime problem and should be coordinated by the owner of the file.

Concurrent reads do not imply concurrent buffer access is safe

ReaderAt permits parallel calls on the same input source. It does not make shared destination memory safe.

This is fine:

left := make([]byte, 4096)
right := make([]byte, 4096)

// Two goroutines call ReadAt into different buffers.

This is risky without synchronization:

buf := make([]byte, 4096)

// Two goroutines call ReadAt into the same buf at the same time.

Each ReadAt call is allowed to use all of its p slice as scratch space during the call. Give concurrent calls non-overlapping destination buffers unless you provide your own synchronization.

The same reasoning applies to overlapping subslices of one backing array.

SectionReader turns a file region into its own reader

Sometimes the rest of your code expects an io.Reader, not an io.ReaderAt.

io.NewSectionReader bridges that gap. It creates a reader over a bounded region of an underlying io.ReaderAt.

section := io.NewSectionReader(file, 4096, 1024)

That section behaves like a 1024-byte logical stream whose byte zero corresponds to offset 4096 in the underlying source.

You can then pass it to APIs that consume an io.Reader:

section := io.NewSectionReader(file, payloadOffset, payloadSize)

data, err := io.ReadAll(section)
if err != nil {
    return err
}

This is useful when a parser should see only one record or embedded object rather than the whole file.

The boundary is enforced by the SectionReader: reads stop at the configured section length even though the underlying file may contain much more data.

SectionReader has its own seek position

A SectionReader also implements Read, ReadAt, and Seek for its logical section.

Its ordinary Read and Seek methods operate on the section’s own current offset. Therefore, if multiple goroutines share one SectionReader and use Read or Seek, they again share cursor state at that layer.

If independent callers need positional access, use the section’s ReadAt method or give each caller its own SectionReader.

The useful distinction is:

*os.File + ReadAt
    explicit absolute offsets

SectionReader + Read
    bounded sequential view with its own cursor

SectionReader + ReadAt
    explicit offsets relative to the section

Choose the interface that matches how the caller thinks about the data.

Validate offsets and lengths before allocating or reading

Random-access code often gets offsets and lengths from a file header or index. If the file is untrusted, those values are untrusted too.

Before allocating a buffer or constructing a section, validate the region against the known file size.

A safe range check should avoid integer overflow. Instead of testing off+size <= fileSize, rearrange the condition:

func validRegion(fileSize, off, size int64) bool {
    if fileSize < 0 || off < 0 || size < 0 {
        return false
    }
    if off > fileSize {
        return false
    }
    return size <= fileSize-off
}

Why avoid the direct addition?

if off+size <= fileSize {
    // unsafe check if off+size overflows int64
}

With attacker-controlled metadata, a sufficiently large off and size can overflow their sum before the comparison. Checking size <= fileSize-off after confirming off <= fileSize avoids that addition.

Validation of the byte range is only one boundary. If you later convert size to int for make([]byte, size), also ensure the value fits the platform’s int range and your application’s memory budget.

A file can change after you validate its size

A successful Stat is a snapshot, not a lock on file contents.

Another process may truncate or replace a file after you validate a region. If that can happen in your environment, ReadAt may still return a short read and io.EOF even though the earlier size check passed.

Therefore:

  • use size checks to reject obviously invalid metadata before work begins;
  • still handle short reads from the actual ReadAt call;
  • define whether concurrent external modification is allowed for the file format or workflow.

If the application requires an immutable input, enforce that property through ownership, file lifecycle, permissions, snapshotting, or another mechanism appropriate to the system. ReadAt itself does not make a mutable file immutable.

ReadAt avoids cursor races, not all concurrency problems

Using positional reads removes one important source of interference: the shared file offset.

It does not automatically solve:

  • closing the file while reads are active;
  • modifying the same file concurrently;
  • racing on shared destination buffers;
  • unbounded goroutine creation;
  • excessive memory allocation;
  • poor locality from many tiny random reads.

Random access can also be slower than sequential access for some storage and workload patterns. On rotational media, scattered reads can require physical seeks. On SSDs and cached files, the cost profile is different, but many small I/O operations still carry syscall and coordination overhead.

Use concurrency because the workload benefits from independent outstanding work, not because ReaderAt promises a speedup. Its primary benefit is a cleaner positional-access contract.

When Seek plus Read is still simpler

ReadAt is not automatically the best choice for every file operation.

Use ordinary Read when one owner consumes a stream sequentially. It naturally expresses “read the next bytes.”

Use Seek plus Read when one goroutine owns the file cursor and the algorithm itself is naturally cursor-oriented—for example, an interactive parser that occasionally skips forward and then continues sequentially.

Use ReadAt when:

  • offsets come from an index or file format;
  • independent parts of a file can be read separately;
  • multiple goroutines need positional reads from one source;
  • you want functions to avoid depending on hidden cursor state.

Use SectionReader when a bounded file region should look like an independent stream or sub-file to downstream code.

Common mistakes

Using Seek and Read concurrently without protecting the pair

A mutex around Seek alone is not enough. Another goroutine can seek before the first goroutine reads. The entire cursor-dependent sequence must be serialized, or replaced with positional reads.

Treating any non-nil error as if no data was returned

A short ReadAt can return useful bytes together with an error. Inspect n and use p[:n] when partial data is meaningful.

Assuming a full generic ReaderAt read must have err == nil

The interface permits io.EOF together with a complete read at the exact end of the source. Generic helpers should decide success from the requested byte count and then handle the allowed EOF case.

Sharing one destination buffer between concurrent calls

Parallel source access does not make overlapping writes to p safe.

Trusting file offsets from untrusted metadata

Validate offsets, sizes, integer conversions, and application-level memory limits before allocation and parsing.

Conclusion

io.ReaderAt is useful because it makes location explicit. Instead of mutating a shared cursor and hoping the next Read sees the intended position, each call says exactly which region it needs.

That model prevents a class of Seek-and-Read interference bugs and supports parallel positional reads by contract. Correct code still needs to handle short reads, EOF rules, file lifetime, mutable inputs, safe buffer ownership, and untrusted offsets.

Use sequential readers for sequential work. Use SectionReader when a bounded region should behave like its own stream. Use ReaderAt when the data model is naturally “read this region at this offset.” Matching the interface to that mental model keeps random-access code explicit and maintainable.