A file stream isn't always read or written from start to finish. Sometimes the code needs to skip a 16-byte header, jump back to a specific record, measure the file's size, or update one field inside an existing record without rewriting the whole file. C++ exposes these patterns through four member functions on file streams: tellg, tellp, seekg, and seekp. They report and change the current position of the file pointer, the abstract cursor that says where the next read or write will happen. This lesson covers what the file pointer is, how the four functions are used, the three seek directions (beg, cur, end), the patterns this enables (skipping headers, file-size measurement, in-place updates), and the subtle differences between text and binary mode.
Every open file stream tracks a current position: a byte offset measured from the beginning of the file. Reads and writes happen at that position and advance it forward by the number of bytes processed. Sequential I/O is the special case where the program never explicitly moves the position; positioning makes that cursor controllable.
There are technically two pointers, one for reading and one for writing, but on a std::ifstream only the read pointer matters and on a std::ofstream only the write pointer matters. On a std::fstream both exist, and on most platforms they share the same underlying offset, which is why mixing reads and writes on the same handle requires a seek between them.
The naming convention in the standard library uses g for "get" (read) and p for "put" (write). So tellg returns the read position, tellp returns the write position, seekg moves the read position, and seekp moves the write position. On ifstream the g versions are the only ones that do anything; on ofstream the p versions are the ones to use.
| Function | What it does | Class it belongs to |
|---|---|---|
tellg() | Returns the current read position | std::istream (so ifstream, fstream) |
tellp() | Returns the current write position | std::ostream (so ofstream, fstream) |
seekg(pos) / seekg(off, dir) | Moves the read position | std::istream |
seekp(pos) / seekp(off, dir) | Moves the write position | std::ostream |
All four are member functions defined in the iostream hierarchy, not in <fstream> specifically. They work on string streams too, though file streams are by far the most common use.
Output:
tellg reports the byte offset where the next read will happen. It starts at 0 and advances by one for each character read. The return type is std::streampos, which is convertible to a numeric type but is not exactly an integer; it's a position type that the standard reserves the right to extend (for example, to track encoding state in multi-byte streams). For everyday use, treat it as an integer-like value.
tellp works the same way for the write pointer. If you write 13 bytes to a fresh ofstream, tellp() returns 13 afterwards. After writing a record, tellp reports exactly how much was written, which means the program can record the offset and seek back to it later.
The simplest use of positioning is asking "where am I in the file?" That's tellg on an input stream or tellp on an output stream. Both return a std::streampos. The return value is -1 (specifically, pos_type(-1)) if the stream is in a fail state, so a defensive program checks the stream's state before trusting the value.
A common application: measuring the size of a file by seeking to the end and asking where that is.
Output:
The std::ios::ate flag means "open the file and seek to the end immediately." Combined with std::ios::binary (covered below), the position reported by tellg is the file's exact size in bytes. This is the standard idiom for asking the OS "how big is this file?" without using a platform-specific API like stat() on Linux or GetFileSizeEx on Windows.
The same approach works without ate by opening normally and then seeking explicitly:
Both versions report the same number. The ate form is shorter; the explicit form leaves the position controllable and is the right shape when the program needs to do other things after measuring the size (most likely: read the file).
Quick Check: What does tellg() return immediately after opening a file with std::ios::ate?
<details> <summary>Answer</summary>
B. The ate flag tells the constructor to seek to the end of the file right after opening. tellg() then reports the position at the end, which is the file's byte length.
</details>
seekg and seekp move the file pointer. Both come in two overloads. The single-argument form takes an absolute position (a std::streampos, which is usually obtained from a previous tellg/tellp call). The two-argument form takes an offset and a direction, which is what most code uses.
The directions are constants in std::ios (technically std::ios_base):
| Direction | Where it measures from |
|---|---|
std::ios::beg | The beginning of the file |
std::ios::cur | The current position |
std::ios::end | The end of the file |
The offset can be positive or negative. Combining the three directions with positive and negative offsets covers every common navigation case.
Output:
Three navigation patterns in nine lines: absolute jump, relative skip, and back-from-end. The file contains the 26-letter alphabet, so byte 5 is F (counting from 0), byte 8 is I, and 3 bytes before the end is X.
The single-argument form takes a std::streampos directly. This is most useful when the program saved a position with tellg earlier and wants to return to it.
Output:
The clear() call before the seek resets the stream's state flags. After reading past the end of the file, failbit and eofbit are both set. A seek on its own does not clear them, so any read that follows would still fail until the flags are reset. The pattern stream.clear(); stream.seekg(pos); is the idiomatic way to recover from a stream that's been read to exhaustion.
The diagram lays out the three direction options. beg treats the offset as an absolute position. cur treats it as a delta from where the pointer is right now (positive moves forward, negative moves back). end treats it as a delta from the file's last byte (almost always negative or zero). All three end up at the teal box: the next read or write happens at the new position.
A common use of seeking is skipping past a fixed-size header at the start of a file. Many binary file formats put metadata in the first N bytes (a magic number, a version, record counts, an index offset), and the code that reads the records doesn't care about that prefix. A single seekg past the header is faster and clearer than reading and discarding N bytes.
Output:
The seek to byte 12 puts the read pointer right at the first body byte, and the loop reads the body without ever touching the header. The std::ios::binary flag prevents any text-mode translation from getting in the way (more on that below).
The same pattern shows up in real formats: a PNG file starts with an 8-byte signature, a ZIP archive ends with a directory whose offset is stored in the last few bytes, and many proprietary formats reserve the first N bytes for a structured header. The combination of tellg, seekg, and a known header size is enough to navigate any of them.
A slightly more interesting case is when the header tells you where the body is, rather than the body always starting at a fixed offset.
Output:
The header carries an offset (4 bytes), the file pads to that offset, and then the body sits at the offset specified. The reader reads the offset first, then seeks to it, then reads the body. This is how indexed file formats work at the lowest level. The seek lets you skip metadata you don't need and jump straight to the data you do.
For binary files of fixed-size records, positioning lets you update one record without rewriting the file. The pattern: open the file in read-and-write binary mode, seek to the record's offset, write the new bytes, done. This is much faster than the text-file alternative (read the whole file, modify in memory, write the whole file back), and it scales to files much larger than memory.
Output:
The middle block opens the file as a std::fstream with both ios::in and ios::out flags. That's the read-and-write mode. The seekg and seekp calls move the same underlying pointer (since this is a real file on disk, both pointers refer to the same OS-level file position). Switching directions on the same handle (read then write) requires a seek in between; that's a rule the standard imposes to give the buffer time to flush correctly.
The math is straightforward: each record is sizeof(ProductRecord) bytes, and record index N starts at offset N * sizeof(ProductRecord). That's the property that makes fixed-size records so easy to update. Variable-size records (anything with a std::string or other dynamic-length field) lose this property and need an indirection layer (a separate index file mapping record IDs to offsets) to support in-place updates.
This pattern only works for fixed-size records. Variable-size records would shift everything after them when modified, which means rewriting the file from the modified point on. For variable data, a database or a structured format with an index is usually a better fit.
Quick Check: A file contains 1000 fixed-size records, each 64 bytes. Where in the file is record number 250 (counting from 0)?
<details> <summary>Answer</summary>
C. Record 250 starts at byte 250 * 64 = 16000. The first record is at byte 0, the second at byte 64, and so on. A seekg(16000) would put the read pointer at the start of record 250.
</details>
The standard idiom for measuring a file's size combines seekg(0, std::ios::end) with tellg(). The seek puts the pointer at the byte just past the last byte of the file; tellg then returns that position, which equals the byte length. This works on any std::ifstream opened in binary mode.
Output:
The function uses binary mode deliberately. In text mode on Windows, the OS translates \r\n to \n when reading and the reverse when writing, which means the byte count seen by the application can differ from the actual on-disk size. Binary mode disables the translation, so what tellg reports is the real size. On Linux and macOS there's no translation either way and binary mode is harmless. Always use binary mode for size calculations.
The C++17 filesystem library has a more direct API: std::filesystem::file_size(path). It's the modern choice when only the size matters. The seek-and-tell idiom is still useful when the program is going to read the file afterwards anyway, since the stream is already open.
There's a subtlety inside seekg and tellg on text-mode streams. On Windows, a text-mode stream translates the two-byte sequence \r\n on disk into the single byte \n in memory. That means the byte offsets reported by tellg and used by seekg may not correspond directly to bytes on disk.
The C++ standard handles this by treating positions on text-mode streams as opaque tokens. You can save a position with tellg and pass it back to seekg, and the round-trip works. But you cannot reliably do arithmetic on text-mode positions: adding 1 to a std::streampos is not guaranteed to mean "the next byte on disk." It's defined only for positions that came from tellg itself.
Output:
The round-trip is safe because the seekg argument came from a tellg call on the same stream. What's not safe (on Windows, in text mode) is computing a position by hand: writing seekg(6, std::ios::beg) and expecting to land at the start of the second line, because the on-disk bytes might include a \r that the text-mode read swallowed.
Binary mode is the fix for arithmetic. In binary mode, no translation happens. Byte offsets are exact, and seekg(N, std::ios::beg) lands at byte N of the file regardless of platform. This is the mode that record-update patterns use, and it's why every fixed-record example in this lesson opens with std::ios::binary.
| Mode | tellg/seekg semantics | Use for |
|---|---|---|
| Text (default) | Opaque tokens, round-trip only | Reading human-readable text where positions don't need arithmetic |
Binary (std::ios::binary) | Exact byte offsets | Random access, record updates, size calculation, anything that needs precise positioning |
The Linux and macOS platforms don't translate line endings, so the distinction matters less there. Code that has to work on Windows too should use binary mode for any positioning-heavy work. A program that reads sequentially with >> or getline and never seeks is fine in text mode.
A small reproduction of the trap, conceptual rather than runnable:
The same code with std::ios::binary always lands at byte 6 of the on-disk file. If the file uses \r\n line endings, byte 6 is part of the line terminator, which is what's on disk. The arithmetic is clear.
A pattern that handles either case: read line by line with std::getline, save positions with tellg after each line, and seek with the saved values. The round-trip is always defined.
Output:
The positions came from tellg, so the seek round-trip is safe on every platform. This is the right pattern for building a "jump to line N" index over a text file.
A self-contained example that uses every concept from this lesson: a binary file of fixed-size product records, with the ability to look up by record number, update by record number, and print the file's record count from its size.
Output:
Four functions, each using a different positioning pattern. writeInitial writes records sequentially, which doesn't need positioning. recordCount uses ios::ate plus tellg to measure the file's size, then divides by the record size. readAt uses seekg with beg to jump to record N. updateStock uses std::fstream, a paired seekg/seekp, and an in-place write to modify one record without touching the rest of the file. The binary mode flag is on for every file open, because the math depends on byte counts being exact.
Q1: What's the difference between `tellg` and `tellp`?
Both report the current position in a file, but tellg is for the read pointer (the "get" position) and tellp is for the write pointer (the "put" position). On an input stream like std::ifstream, only tellg is meaningful. On an output stream like std::ofstream, only tellp is meaningful. On std::fstream both exist; on most platforms they refer to the same underlying file offset, so a read advances the pointer that the next write will start from, and vice versa. The naming convention with g and p extends across the standard library: seekg and seekp, pubseekoff for streambufs, and so on.
Q2: What are the three `seekdir` values, and when would you use each?
std::ios::beg measures the offset from the beginning of the file. Use it for absolute positioning: "seek to byte 1024" or "seek to the start of record number N". std::ios::cur measures from the current position. Use it for relative skips: "advance 16 bytes past the header I just read" or "back up 4 bytes to re-read the length field". std::ios::end measures from the end of the file. Use it for measuring size (seekg(0, std::ios::end) followed by tellg) or for reading the trailer of a format like ZIP that stores its directory at the end.
Q3: Why is text mode positioning unreliable on Windows, and how do you avoid the issue?
On Windows, files use \r\n as a line terminator on disk, but C++ text-mode streams translate \r\n to \n on read and the reverse on write. That means the byte offsets reported by tellg may not correspond to actual on-disk byte positions, and arithmetic on text-mode positions is not portable. The standard guarantees only that positions obtained from tellg can be safely passed back to seekg; computed positions are undefined. The fix is to open the file in binary mode with std::ios::binary, which disables the translation and makes positions correspond to literal on-disk bytes. Any code that does arithmetic on file positions, calculates record offsets, or measures file size should use binary mode.
Q4: How do you measure the size of a file in C++ using stream operations?
Open the file in binary mode, seek to the end, and call tellg. The shortest form uses the std::ios::ate flag, which seeks to the end automatically as part of opening: std::ifstream in(path, std::ios::binary | std::ios::ate) followed by in.tellg(). Binary mode is important because text mode might convert line endings on Windows, changing the count. C++17's std::filesystem::file_size is a more direct alternative when only the size is needed.
Q5: When you update one record in the middle of a binary file, why is `std::fstream` the right class to use?
Updating a record in place requires both reading (to find the record's current contents, or to seek past it) and writing (to change the bytes). std::ifstream is read-only and std::ofstream is write-only, so neither fits. std::fstream opens with both ios::in and ios::out by default, which means the same handle can do both. The same file offset is used for both reads and writes, so a seekg followed by a seekp to the same position lines up correctly. One subtlety: the standard requires a seek between switching directions on the same stream (read-then-write or write-then-read), to let the buffer flush correctly.
Exercise 1: Write a function fileSize(const std::string& path) that returns the size of a file in bytes, using seekg and tellg. Return -1 if the file can't be opened. Test it on a file you create with known content.
Expected Output:
<details> <summary>Solution</summary>
Hello, World! is 13 characters with no newline. Binary mode keeps the count platform-independent.
</details>
Exercise 2: What does this program print?
Expected Output:
<details> <summary>Solution</summary>
After seekg(3, beg), the pointer is at byte 3 (D). get reads D and advances to 4. seekg(-2, cur) moves to 2 (C). get reads C and advances to 3. seekg(-1, end) moves to the last byte (H). get reads H. The output is DCH.
</details>
Exercise 3: Fix the bug. The program should print second but prints an empty string.
Expected Output (after the fix):
<details> <summary>Solution</summary>
After reading past the end, failbit and eofbit are both set. The seek doesn't clear them on its own, so the next read fails before doing any work. in.clear() resets the flags. Order matters: clear first, then seek, then read.
</details>
Exercise 4: Write a program that creates a binary file of 10 int values (1 through 10) and then prints the value at index 7 using seekg.
Expected Output:
<details> <summary>Solution</summary>
Each int is 4 bytes (on most platforms), so index 7 starts at byte 28.
</details>
Exercise 5: Write a function countRecords(const std::string& path, std::size_t recordSize) that returns the number of fixed-size records in a binary file. Test it with a file of 5 double values.
Expected Output:
<details> <summary>Solution</summary>
ios::ate seeks to the end on open; dividing the byte count by the record size gives the record count.
</details>
Exercise 6: Predict the output of this program.
Expected Output:
<details> <summary>Solution</summary>
After writing 12345, the put position is 5. seekp(2) jumps to position 2 (the byte containing 3). Writing X overwrites that byte and advances the put position to 3. The final file contains 12X45.
</details>
Exercise 7: Write a program that creates a binary file of three fixed-size records (each containing a 16-byte name and a 4-byte int), then updates the int field of record index 1 to a new value, and reads the file back.
Expected Output:
<details> <summary>Solution</summary>
std::fstream with in | out | binary is the right combination for in-place updates.
</details>
Exercise 8: Modify this code so that the seek round-trip is safe on every platform, including Windows.
Expected Output:
(reliably, on every platform)
<details> <summary>Solution</summary>
Open both streams in binary mode. With binary mode the on-disk content is alpha\nbeta\ngamma\n (17 bytes), and byte 6 is the b of beta. Without binary mode, Windows would write \r\n for each line terminator and the byte count would be different, and the arithmetic seekg(6, beg) would land somewhere unintended.
</details>
10 quizzes