8.3 · Seek and Byte-Level Navigation
Domain 8 — I/O System Duration: ~15 minutes Library components:
std::io::Seek,std::io::SeekFrom
Introduction
Read and Write move through a stream in one direction, from the start to the end. Seek lets you go to any position in the stream. One trait method, seek(SeekFrom) -> io::Result<u64>, and the three anchors of the SeekFrom enum are sufficient to move through any random-access stream. Such streams are files, cursors, and all types that wrap them.
This tutorial shows:
SeekFrom::Start,SeekFrom::Current,SeekFrom::End, and the convenience methodsstream_positionandrewind.- The edge cases: a seek to before byte 0 (an error) and a seek past EOF (legal, with special write semantics).
- The stale-buffer problem when you use
Seektogether withBufReader, andseek_relativeas the efficient solution. - The offset-table use case on a real file: fixed-size records, trailers at a known distance from the end, and patches in place.
SeekFrom: Three Anchors
You give each seek relative to one of three anchors. Start takes a u64, because no position exists before the start. Current and End take an i64, because a seek from these anchors must be able to go backward.
Figure: The three SeekFrom anchors over a 26-byte stream
seek returns the new absolute position. Use that return value, not a second call that queries the position:
// These lines are in a function that returns io::Result<()>, so `?` is valid.
// A Cursor<&[u8]> on the 26 letters: 'A' is at position 0, 'Z' is at position 25.
let mut cur = Cursor::new(b"ABCDEFGHIJKLMNOPQRSTUVWXYZ".as_slice());
let pos = cur.seek(SeekFrom::Start(9))?; // pos == 9, next read: 'J'
let pos = cur.seek(SeekFrom::Current(5))?; // pos == 14 (9 + 5), next read: 'O'
let pos = cur.seek(SeekFrom::End(-1))?; // pos == 25, next read: 'Z'
SeekFrom::End is the usual method to read trailers. ZIP central directories, database footers, and checksum blocks are all at known distances from EOF. You can get to them when you do not know the length of the file.
Two convenience methods are short forms of seek: stream_position() is seek(SeekFrom::Current(0)), and rewind() is seek(SeekFrom::Start(0)). No stable stream_len exists. The portable procedure has three steps:
- Save the position.
- Call
seek(End(0)), which returns the length. - Seek to the saved position.
The Edges: Before Zero and Past EOF
The behavior at the two limits is not symmetrical, and each behavior is important in parser code:
-
A seek to before byte 0 is an error. A seek that gives a negative position fails with
ErrorKind::InvalidInput. The position does not change, which is important:// cur: the Cursor on the 26 letters from the previous snippet. cur.seek(SeekFrom::Start(10))?; let err = cur.seek(SeekFrom::Current(-11)).unwrap_err(); // 10 - 11 = -1 assert_eq!(err.kind(), io::ErrorKind::InvalidInput); assert_eq!(cur.stream_position()?, 10); // the failed seek did not move the position -
A seek past EOF is legal. A seek only sets a number. It does not compare the number with the stream length. A read at that position returns EOF (
Ok(0)) immediately. A write at that position, on a target that can grow, fills the gap with zeros. Sparse files behave this way on most filesystems, andCursor<Vec<u8>>does the same:let mut cur = Cursor::new(b"1234".to_vec()); // Cursor<Vec<u8>>, 4 bytes cur.seek(SeekFrom::Start(8))?; // 4 bytes past EOF: no error cur.write_all(b"XY")?; // the Vec grows to 10 bytes assert_eq!(cur.get_ref(), b"1234\0\0\0\0XY"); // bytes 4 to 7 are zeros
The BufReader Stale-Buffer Problem
BufReader (tutorial 8.1) reads ahead of the bytes that you consume. If you request 4 bytes, it may read 8 KiB from the source. From that moment, two positions exist:
- The position of the underlying stream, which is far ahead.
- The logical position, which is the point that your code consumed to.
Each Seek bug with a buffered reader occurs because code uses one position as the other.
Figure: Why seeking the inner reader serves stale bytes
The example uses assertions to show the wrong bytes:
// data: a Vec<u8> with the 26 letters 'A' to 'Z'. four: [u8; 4], the read buffer.
let mut reader = BufReader::with_capacity(8, Cursor::new(data)); // an 8-byte buffer
reader.read_exact(&mut four)?; // ABCD consumed, EFGH buffered
reader.get_mut().seek(SeekFrom::Start(0))?; // WRONG: a seek directly on the inner reader
reader.read_exact(&mut four)?;
assert_eq!(&four, b"EFGH"); // stale buffer, wrong data!
assert_eq!(reader.stream_position()?, 0); // the reported position is also wrong
The rule: after you wrap a reader, do all navigation through the wrapper. The Seek implementation of BufReader keeps the two positions consistent, but it has a cost. Each seek discards the buffer, and the next read must get the bytes from the source again. This is true even for a 2-byte move that the buffer could serve.
seek_relative: Keeping the Buffer
BufReader::seek_relative(i64) removes this cost. It behaves as seek(SeekFrom::Current(n)) does, with one difference. When the target is inside the buffered bytes, it only changes the buffer index. It does not discard the buffer, and it does not fill the buffer again:
// data and four: the same as in the previous snippet.
let mut reader = BufReader::with_capacity(8, Cursor::new(data));
reader.read_exact(&mut four)?; // ABCD consumed, EFGH buffered
assert_eq!(reader.buffer().len(), 4); // buffer() returns the bytes not consumed yet
reader.seek_relative(2)?; // skip E and F: the target is in the buffer
assert_eq!(reader.buffer().len(), 2); // the buffer is KEPT, only its index moved
reader.read_exact(&mut four)?;
assert_eq!(&four, b"GHIJ"); // correct bytes: G and H come from the kept buffer
A move to a target outside the buffered range becomes a real seek. Use seek_relative when you skip small, known amounts in a buffered stream:
- padding bytes
- fixed-size fields that you do not need
- alignment gaps
Then each skip is an O(1) index change. Without seek_relative, each skip discards an 8 KiB buffer.
08_09_bufreader_seek_gotcha.rs prints:
consumed 4, source at 8, buffer holds 4 — logical pos = 4
GOTCHA: after get_mut().seek(0), next read was EFGH, not ABCD
seek through BufReader: correct ABCD, but the buffer was dropped
seek_relative(2): buffer kept (2 left), read GHIJ; long hop -> U
All assertions passed.
Use Case: A Fixed-Size Record Store
The offset-table pattern from tutorial 8.2 also applies to real files. With fixed-size records, arithmetic is the offset table: record i starts at header_len + i * record_len. The example writes a small store to disk, in a unique temporary directory that it removes at the end. Then it uses the three seek patterns:
// HEADER_LEN is 8 and RECORD_LEN is 12. Both are u64 constants.
fn record_offset(index: u32) -> u64 {
HEADER_LEN + u64::from(index) * RECORD_LEN // record 3 starts at byte 44
}
// file: a File open for reading. The store has 5 records (68 bytes).
// O(1) random access, independent of the number of records before this one:
file.seek(SeekFrom::Start(record_offset(3)))?;
// The last record, when you do not know the count (12 bytes before EOF):
file.seek(SeekFrom::End(-i64::try_from(RECORD_LEN).expect("fits i64")))?;
// A patch in place: a File is Read + Write + Seek when you open it for read and write.
let mut rw = OpenOptions::new().read(true).write(true).open(&path)?;
let mut rec1 = read_record(&mut rw, 1)?; // example function: seek, then read 12 bytes
rec1.score += 990; // 10 -> 1000
rw.seek(SeekFrom::Start(record_offset(1)))?; // back to the start of record 1
rw.write_all(&rec1.to_bytes())?; // 12 bytes: the file length does not change
Database pages, game asset packs, and font tables use this access pattern. All the code is generic over Read + Write + Seek, so the same functions operate on a Cursor<Vec<u8>> in tests. Domain 9 (tutorial 9.1) gives the details of file creation and OpenOptions.
Summary
| Concept | Key point |
|---|---|
SeekFrom::Start(u64) | An absolute position. Negative positions do not exist. |
SeekFrom::Current(i64) | A signed move from the current position |
SeekFrom::End(i64) | A position relative to the end: the usual method for trailers and footers |
seek return value | The new absolute position. Use it, and do not query the position again. |
stream_position / rewind | Short forms of Current(0) / Start(0) |
| Stream length | No stable stream_len exists. Save the position, seek to End(0), then seek to the saved position. |
| Seek before 0 | InvalidInput, and the position does not change |
| Seek past EOF | Legal. Reads return EOF, and writes fill the gap with zeros (as in a sparse file). |
get_mut() + seek | The stale-buffer problem: the buffer serves wrong bytes, and the reported position is wrong |
Seek through BufReader | The bytes and the position are consistent, but each seek discards the whole buffer |
seek_relative | A move inside the buffer keeps the buffer. It is the efficient skip. |
| Fixed-size records | The arithmetic header + i × size replaces a stored offset table |
Code Examples
| File | Description |
|---|---|
08_08_seek_seekfrom.rs | The three anchors, stream_position/rewind, the stream length procedure, errors for negative positions, reads past EOF, and zero-filled writes |
08_09_bufreader_seek_gotcha.rs | The two positions of a read-ahead buffer and the stale-buffer bug with observable wrong bytes. A seek through the wrapper compared with seek_relative, which keeps the buffer |
08_10_offset_table_records.rs | A store of fixed-size records in a real temporary file: O(1) record access, the last record with SeekFrom::End, and a patch in place |