• English
  • Understanding io.EOF in Go

    io.EOF is one of the most commonly misunderstood values in Go.

    It is an error, but reaching io.EOF does not usually mean something went wrong.

    The key question is:

    Was reaching the end of the input valid for this operation?

    Once you understand that distinction, io.EOF becomes much easier to reason about.


    Quick Example

    A Reader may return data first and io.EOF later:

    package main
    
    import (
    	"fmt"
    	"io"
    	"log"
    	"strings"
    )
    
    func main() {
    	r := strings.NewReader("hello")
    	buf := make([]byte, 4)
    
    	for {
    		n, err := r.Read(buf)
    
    		if n > 0 {
    			fmt.Printf("read %q\n", buf[:n])
    		}
    
    		if err != nil {
    			// Direct comparison is fine here because we consume
    			// the error in the same function where Read returns it.
    			if err == io.EOF {
    				break
    			}
    			log.Fatal(err)
    		}
    	}
    }

    Output:

    read "hell"
    read "o"

    The final read reaches the end of the input.

    That is not a failure. It is how the reader tells the caller:

    There is no more data.


    What Does io.EOF Actually Mean?

    io.EOF means:

    The reader has no more input available.

    It does not inherently mean:

    • the operation failed
    • the input is invalid
    • a resource was closed
    • a network connection was closed
    • a protocol message is complete
    • the input is valid

    Those are separate questions.

    io.EOF only describes the state of the input stream.

    Think of it as a stream boundary, not automatically as an error condition.


    io.EOF Is a Sentinel Error

    io.EOF is a sentinel error: a predefined package-level error value used to represent a specific condition.

    The standard library defines it as:

    var EOF = errors.New("EOF")

    Because it is a specific shared value, direct comparison works:

    if err == io.EOF {
    	// end of input
    }

    If an error wraps io.EOF, direct comparison no longer works:

    err := fmt.Errorf("reading body: %w", io.EOF)
    
    fmt.Println(err == io.EOF) // false

    Use errors.Is when wrapped errors are possible:

    if errors.Is(err, io.EOF) {
    	// end of input
    }

    Also note:

    errors.Is(io.EOF, io.EOF) // true

    The important point is that io.EOF is an actual sentinel value, not a special error type that callers need to type-assert.


    io.EOF Is an Error Value, Not Usually an Error Condition

    Go uses the error interface to report many different conditions.

    Some of those conditions represent failures.

    Others represent normal control flow.

    io.EOF belongs to the second category in many common reader operations.

    For example:

    for {
    	n, err := r.Read(buf)
    
    	if n > 0 {
    		process(buf[:n])
    	}
    
    	if err == io.EOF {
    		break
    	}
    
    	if err != nil {
    		return err
    	}
    }

    Here, EOF means the loop has completed normally.

    The important distinction is:

    error value       !=       failure condition

    io.EOF is an error value, but reaching the end of input is often expected.


    The Most Important Rule: Process n Before err

    This is the rule that prevents many EOF-related bugs.

    A Reader returns two pieces of information:

    n, err := r.Read(buf)

    You must process the bytes represented by n before interpreting err.

    A reader can legally return data together with an error:

    n > 0
    err != nil

    For example:

    n, err := r.Read(buf)
    
    if n > 0 {
    	process(buf[:n])
    }
    
    if err != nil {
    	handle(err)
    }

    Do not write code that assumes an error means no data was returned:

    // Wrong:
    n, err := r.Read(buf)
    
    if err != nil {
    	return err
    }
    
    process(buf[:n])

    If the reader returns:

    n = 5
    err = io.EOF

    the code above loses the five bytes.

    This also matters when using buffered readers such as bufio.Reader: a read can return data together with io.EOF.

    The general rule is:

    Process n first. Then process err.


    Usually, You Should Not Handle EOF Yourself

    Many higher-level I/O functions already interpret normal EOF for you.

    io.Copy

    Consider:

    _, err := io.Copy(dst, src)
    if err != nil {
    	return err
    }

    You do not normally write:

    if err == io.EOF {
    	// copy completed
    }

    io.Copy treats io.EOF from the source as normal completion.

    A successful io.Copy returns nil, not io.EOF.

    Other source read errors are returned to the caller rather than being treated as successful completion. This includes errors such as io.ErrUnexpectedEOF and network errors.

    So:

    source reaches EOF
    
        io.Copy
    
    normal completion
    
        nil error

    The EOF has already been consumed as part of the copy operation.


    io.ReadAll

    io.ReadAll follows the same basic idea:

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

    It reads until the reader reaches EOF.

    Normal EOF is treated as successful completion:

    Reader → io.EOF
    
          io.ReadAll
    
           []byte, nil

    If the reader returns another error, io.ReadAll returns the data accumulated so far together with that error.

    For example:

    read "hello"
    read " world"
    read some data
    read error
    
    data = everything read so far
    err  = the non-EOF error

    Do not use data == nil as a generic test for whether the input contained data.

    If you need to know whether any bytes were returned, use:

    len(data) == 0

    The important semantic fact is the length of the returned data, not whether the slice happens to be nil.


    EOF vs io.ErrUnexpectedEOF

    This distinction becomes important when the caller expects a specific amount of data.

    Suppose a protocol defines an 8-byte header.

    You can use:

    header := make([]byte, 8)
    
    _, err := io.ReadFull(r, header)
    if err != nil {
    	return err
    }

    There are two important cases.

    No bytes are available

    If the reader immediately reaches EOF:

    0 bytes read
    
    io.EOF

    The input simply ended before any header bytes arrived.

    Some bytes were read

    If only part of the header arrives:

    5 bytes read
    
    EOF
    
    io.ErrUnexpectedEOF

    This means the input ended unexpectedly while a fixed-size value was still incomplete.

    That distinction is extremely useful in protocol parsing:

    EOF
        = input ended
    
    ErrUnexpectedEOF
        = input ended before the required value was complete

    The same underlying stream boundary can therefore have different meanings depending on the operation.


    EOF Does Not Close Resources

    EOF and resource lifetime are separate concepts.

    For example:

    f, err := os.Open("data.txt")
    if err != nil {
    	return err
    }
    defer f.Close()
    
    _, err = io.Copy(dst, f)
    if err != nil {
    	return err
    }

    The reader reaching EOF does not close the file.

    You still need:

    f.Close()

    Likewise, with HTTP:

    resp, err := http.Get(url)
    if err != nil {
    	return err
    }
    defer resp.Body.Close()
    
    _, err = io.Copy(dst, resp.Body)
    if err != nil {
    	return err
    }

    io.Copy consumes EOF from resp.Body as normal completion.

    Close is a separate resource-lifecycle operation.

    For HTTP clients, consuming the response body and then closing it also matters for connection reuse.

    If a response body is closed before it is fully consumed, the underlying connection may not be reusable and may need to be discarded.

    When the response data is not needed, draining it with:

    io.Copy(io.Discard, resp.Body)

    before Close is a common pattern when connection reuse matters.

    The important mental model is:

    EOF
    
    no more input
    
    Close
    
    release the resource

    They are not interchangeable.


    EOF Does Not Define Protocol Semantics

    EOF belongs to the I/O layer.

    A protocol decides what EOF means.

    For one protocol:

    EOF = normal end of message

    For another:

    EOF = truncated message

    For a fixed-length structure:

    EOF before all bytes arrive = invalid input

    For a stream where EOF itself terminates the message:

    EOF = valid message boundary

    Therefore, code should not blindly translate:

    err == io.EOF

    into:

    the operation succeeded

    The correct question is:

    Was EOF an acceptable boundary for this operation?


    EOF Is Different from Cancellation and Timeout

    EOF, cancellation, and timeout represent different conditions.

    For example:

    if err != nil {
    	switch {
    	case errors.Is(err, io.EOF):
    		// normal end of input
    	case errors.Is(err, context.Canceled):
    		// operation was canceled
    	case errors.Is(err, context.DeadlineExceeded):
    		// operation exceeded its deadline
    	default:
    		// actual I/O or application error
    	}
    }

    These conditions should not be collapsed into one category:

    io.EOF
        → no more input
    
    context.Canceled
        → operation was canceled
    
    context.DeadlineExceeded
        → operation exceeded its deadline

    A cancellation is not equivalent to a clean end-of-input condition.

    In context-aware network or application code, cancellation may cause an underlying operation to stop, but the application should preserve the cancellation semantics rather than arbitrarily converting the result into EOF.


    When Should You Use errors.Is?

    For a directly returned reader error, this is idiomatic:

    if err == io.EOF {
    	break
    }

    Use errors.Is when the error may be wrapped:

    if errors.Is(err, io.EOF) {
    	break
    }

    For example:

    return fmt.Errorf("read request body: %w", err)

    can wrap an underlying error.

    A practical guideline:

    If you consume the error immediately in the same function where it was produced, == is usually sufficient. If the error may be wrapped before reaching its final handler, prefer errors.Is.

    For io.EOF specifically, there is an additional detail worth knowing: the io package documentation requires Reader implementations to return io.EOF itself rather than an error wrapping io.EOF, because callers may compare it with ==.


    Common Mistakes

    1. Treating EOF as a Failure

    Avoid

    if err != nil {
    	log.Printf("read failed: %v", err)
    	return err
    }

    This may incorrectly treat normal EOF as a failure.

    Instead

    if err == io.EOF {
    	return nil
    }
    
    if err != nil {
    	return err
    }

    Or, when appropriate, let io.Copy or io.ReadAll handle EOF for you.


    2. Returning Before Processing n

    Avoid

    n, err := r.Read(buf)
    
    if err != nil {
    	return err
    }
    
    process(buf[:n])

    A reader may return:

    n > 0
    err = io.EOF

    Instead

    n, err := r.Read(buf)
    
    if n > 0 {
    	process(buf[:n])
    }
    
    if err == io.EOF {
    	return nil
    }
    
    if err != nil {
    	return err
    }

    Always process the bytes first.


    3. Assuming EOF Means the Connection Was Closed

    Avoid

    if err == io.EOF {
    	// The TCP connection was closed.
    }

    EOF only tells you that the reader has no more input.

    It does not, by itself, tell you why the stream ended or what happened to the underlying resource.

    Instead

    Interpret EOF at the appropriate abstraction level.

    For example, an HTTP response body reaching EOF means the body has no more data. It does not by itself mean the underlying connection was closed.


    4. Using EOF to Validate Fixed-Size Input

    Avoid

    buf := make([]byte, 8)
    
    n, err := r.Read(buf)
    if err == io.EOF {
    	return nil
    }
    
    if n != 8 {
    	return errors.New("invalid header")
    }

    A single Read is not a reliable way to obtain a fixed number of bytes.

    Instead

    buf := make([]byte, 8)
    
    if _, err := io.ReadFull(r, buf); err != nil {
    	return fmt.Errorf("read header: %w", err)
    }

    Now a truncated header is represented by io.ErrUnexpectedEOF.


    5. Forgetting That io.ReadAll Can Return Partial Data

    Avoid

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

    This may discard useful data that was successfully read before the error.

    Instead

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

    Whether partial data is useful depends on the caller and the protocol.


    6. Treating Cancellation as EOF

    Avoid

    if err == io.EOF || err == context.Canceled {
    	return nil
    }

    This collapses two different conditions into the same result.

    A caller may need to know whether the stream ended normally or the operation was canceled.

    Instead

    if err == io.EOF {
    	return nil
    }
    
    if errors.Is(err, context.Canceled) {
    	return err
    }
    
    if err != nil {
    	return err
    }

    Preserve cancellation semantics unless the surrounding API explicitly defines cancellation as normal completion.


    7. Handling EOF at Every Layer

    Avoid

    func consume(r io.Reader) error {
    	_, err := io.Copy(io.Discard, r)
    
    	if err == io.EOF {
    		return ErrStreamExhausted
    	}
    
    	return err
    }

    io.Copy already treats a normal source EOF as successful completion.

    The helper does not need to translate it into another application-level error.

    Instead

    func consume(r io.Reader) error {
    	_, err := io.Copy(io.Discard, r)
    	return err
    }

    Handle EOF at the layer that actually needs to assign meaning to the end of the stream.


    The Production Mental Model

    When working with a Reader, think in terms of:

                 Reader
    
    
                n, err
    
            ┌───────┴───────┐
            │               │
          n > 0           n == 0
            │               │
       process data       inspect err
            │               │
            └───────┬───────┘
    
               inspect err
    
           ┌────────┴────────┐
           │                 │
        io.EOF          other error
           │                 │
     normal end          handle/return
      of input

    For fixed-length input:

    fixed-length input
    
            ├── no bytes + early EOF
            │       └── io.EOF
    
            └── partial bytes + early EOF
                    └── io.ErrUnexpectedEOF

    The key rules are:

    1. Process n before err.
    2. io.EOF means no more input.
    3. io.EOF is not automatically a failure.
    4. Higher-level I/O functions often consume normal EOF for you.
    5. Other read errors are not equivalent to EOF.
    6. Fixed-length operations may interpret EOF as truncation.
    7. EOF does not close resources.
    8. Cancellation and timeout are different conditions.

    The final question to ask is always:

    Was reaching the end of the input valid for this operation?

    That question is more useful than memorizing "io.EOF means end of file."

    In Go, io.EOF is best understood as a stream boundary whose meaning is determined by the operation consuming it.