C / FILES AND ERRORS
Reading files line by line with fgets
Read a text file one line at a time with fgets, trim the newline, and tell a truncated line and a read error apart from a normal end of file.
What you will learn
- Loop with while (fgets(buf, sizeof buf, f) != NULL) instead of testing feof
- Strip the trailing '\n' with strlen and a check, because fgets deliberately keeps it
- Read a missing newline as either truncation or a file with no final newline
- Call ferror after the loop to separate a read failure from a normal end of file
Understanding Reading files line by line with fgets
fgets takes a destination array, a size, and a stream, and it stops as soon as any one of three things happens: it has copied size-1 bytes, it has copied a newline, or the stream ran out of data. Whatever it copied is terminated with '\0', and the newline, if it reached one, stays in the buffer. That retained newline is not an oversight; it is the only evidence you get that the line ended where the file said it ended rather than where your array ran out of room.
The mental model worth carrying is that your buffer boundary and the file's line boundaries are unrelated. A 64-byte array does not make lines 63 bytes long, it only caps how much one call can hand back, so a 200-character line arrives as four separate successful calls. This is why counting calls to fgets is not the same as counting lines, and why code that treats each returned string as a complete record quietly mangles long input instead of failing loudly.
Trimming is therefore a decision, not a reflex: measure with strlen, and if the last byte is '\n' replace it with '\0' — but if it is not, ask why. Either the buffer filled first and the rest of the line is still queued in the stream, or the file ended without a final newline, which is legal and common. When fgets finally returns NULL you still know nothing about the cause until you ask, so ferror tells you a read failed and feof tells you the data simply ran out.
Because of this, robust line readers are written around the newline, not around the call.
<stdio.h>
<string.h>
int main(void)
{
char line[64];
FILE *f;
int n = 0;
f = fopen("notes.txt", "w");
if (f == NULL) {
perror("notes.txt");
return 1;
}
fputs("alpha\nbeta\ngamma without newline", f);
fclose(f);
f = fopen("notes.txt", "r");
if (f == NULL) {
perror("notes.txt");
return 1;
}
while (fgets(line, sizeof line, f) != NULL) {
size_t len = strlen(line);
int complete = (len > 0 && line[len - 1] == '\n');
if (complete) {
line[len - 1] = '\0';
len--;
}
printf("%d: [%s] len %zu, newline %s\n",
++n, line, len, complete ? "yes" : "no");
}
if (ferror(f))
perror("notes.txt");
fclose(f);
return 0;
}
A successful fgets call means "here are up to size-1 more bytes", not "here is one whole line"; the trailing newline is what proves a line actually ended.
Worked examples
One long line, four successful calls
Shows that fgets splits a line that is wider than the buffer, and that the missing newline is the signal.
<stdio.h>
<string.h>
int main(void)
{
char buf[8];
FILE *f;
int chunk = 0;
f = fopen("long.txt", "w");
if (f == NULL) {
perror("long.txt");
return 1;
}
fputs("0123456789abcdef\nshort\n", f);
fclose(f);
f = fopen("long.txt", "r");
if (f == NULL) {
perror("long.txt");
return 1;
}
while (fgets(buf, sizeof buf, f) != NULL) {
size_t len = strlen(buf);
int complete = (len > 0 && buf[len - 1] == '\n');
if (complete)
buf[len - 1] = '\0';
printf("chunk %d: [%s] %s\n", ++chunk, buf,
complete ? "line ended" : "line continues");
}
fclose(f);
return 0;
}
Example explained
Line 1char buf[8] leaves room for 7 characters plus the terminator, so no single call can return more than 7 bytes of the file.
Line 2Chunks 1 and 2 come back without a '\n', which is fgets saying the array filled up before the line did.
Line 3Chunk 3 is only "ef" plus the newline, so the first line of the file actually ends on the third call.
Line 4The file holds two lines but produced four non-NULL returns, which is why a counter driven by calls reports the wrong number.
Trimming CRLF and skipping blank lines
Trims either line ending from the end of the buffer and uses the resulting empty string to detect a blank line.
<stdio.h>
<string.h>
static void trim_eol(char *s)
{
size_t n = strlen(s);
while (n > 0 && (s[n - 1] == '\n' || s[n - 1] == '\r'))
s[--n] = '\0';
}
int main(void)
{
char line[32];
FILE *f;
int num = 0;
f = fopen("crlf.txt", "wb");
if (f == NULL) {
perror("crlf.txt");
return 1;
}
fputs("one\r\n\r\ntwo\r\n", f);
fclose(f);
f = fopen("crlf.txt", "r");
if (f == NULL) {
perror("crlf.txt");
return 1;
}
while (fgets(line, sizeof line, f) != NULL) {
trim_eol(line);
if (line[0] == '\0')
continue;
printf("%d:%s|\n", ++num, line);
}
fclose(f);
return 0;
}
Example explained
Line 1The file is written in binary mode so the \r\n pairs reach the disk untouched, giving the reader a real CRLF file.
Line 2fgets still stops on the '\n', so each call returns text plus a two-byte ending it does not interpret.
Line 3trim_eol peels bytes off the end while they are '\r' or '\n', so it handles both endings without knowing which the file uses.
Line 4The blank middle line trims down to an empty string, which is exactly what the line[0] == '\0' test skips, and the trailing '|' shows nothing is left after the text.
Important notes
When fgets returns NULL the array is left unchanged on a plain end of file and is indeterminate after a read error, so never inspect the buffer after a failed call.
gets() looked simpler because it had no size argument, which is precisely why it was removed from the language in C11; fgets with a real array size replaces it.
Common mistakes
Writing while (!feof(f)) { fgets(line, sizeof line, f); use(line); }: feof only becomes true after a call has already failed, so the last line is processed twice from the stale buffer.
Passing sizeof buf where buf is a char * function parameter: fgets receives the pointer size, typically 8, and silently chops every line into 7-byte pieces.
Printing with printf("%s\n", line) without trimming: the newline fgets kept plus the one you added puts a blank line after every line of output.
Try it yourself
Change, predict, then run
Write a file with three lines where the middle one is 40 characters long, then read it back with fgets and an 8-byte buffer and print the number of real lines. The count must come out as 3, so it has to be driven by newlines rather than by successful calls.
Open the C workspaceCheck your understanding
A file holds one line of 20 characters followed by a newline. Your loop calls fgets(buf, 8, f) and adds one to a counter every time it returns non-NULL. What does the counter reach by the end of that line, and why?
- 3, because a call copies at most 7 characters, so the third one takes the last 6 plus the newline
- 1, because fgets reads up to the newline no matter how small the buffer is
- 4, because the terminator eats a byte of every chunk, leaving 6 characters per call
- 21, because fgets returns once per character until it reaches the newline
Show answer
The size argument counts the terminator, so each call copies at most 7 characters: 7 + 7 + (6 plus the newline) covers all 21 bytes, giving three non-NULL returns. The tempting answer is 1, the assumption that one call equals one line, but fgets can never exceed the size it was handed; it stops early and leaves the remainder of the line in the stream for the next call.