Files and Command-Line Arguments
A C program becomes useful when it can work with data beyond its own source code. Files let a program persist and consume data; command-line arguments let a person or script choose what the program should do. Both are external boundaries, so every handle, byte, and argument needs an explicit contract.
External data is a boundary, not a promise
fopen can fail. Test the returned pointer before use.fclose on every exit path.argc before argv[index] and parse text before calculation.Files are one kind of stream
Standard I/O uses the same FILE * interface for regular files, the keyboard, terminal output, pipes, and redirected commands. Your program already starts with three streams: stdin for input, stdout for normal results, and stderr for diagnostics. This separation is what makes a command composable.
int main(void) {
fprintf(stdout, "42\n"); /* data another command can consume */
fprintf(stderr, "read 42 rows\n"); /* progress or diagnostic detail */
return 0;
}
/* Useful shell compositions:
./line_count notes.txt > result.txt
./line_count missing.txt 2> errors.txt
./line_count notes.txt | sort
*/Do not print a human status banner to stdout when another program may need machine-readable output. Keep data on stdout, errors and usage guidance on stderr, and let the shell redirect either stream without changing your C source.
A FILE pointer represents an open stream
The standard I/O header, <stdio.h>, declares the FILE type and functions that operate on streams. A FILE * is an opaque handle managed by the C library; do not invent one, copy its internal representation, or use it after closing it.
#include <stdio.h>
int main(void) {
FILE *input = fopen("scores.txt", "r");
if (input == NULL) {
perror("scores.txt");
return 1;
}
/* Read from input here. */
if (fclose(input) == EOF) {
perror("scores.txt");
return 1;
}
return 0;
}fopen returns a valid pointer on success and NULL on failure. Failure is normal: the file may not exist, permissions may deny access, a directory may be supplied instead of a regular file, or the operating system may run out of descriptors. perror prints your contextual label followed by the current system error message.
Choose a mode that matches the intended operation
"r"Read existing data
Fails if the path does not exist. It does not create or modify the file.
"w"Write a new complete result
Creates a new file or truncates an existing one. Do not use it when old content must survive.
"a"Append a new record
Creates if absent and writes at the end. Useful for a deliberate log, not a substitute for validation.
Add + only when the same stream genuinely needs both reading and writing, then follow the standard's positioning/flush rules between direction changes. Add b for binary data. For this lesson, use text mode and write the format your program promises to read.
Text, binary, buffering, and position are separate concerns
Text mode represents text according to the host environment; binary mode treats the stream as bytes. On some platforms, text mode translates line endings, so a byte offset calculated in text mode is not a portable record address. Choose binary mode for a binary format, not merely because it feels lower level.
long file_size(FILE *input) {
if (fseek(input, 0L, SEEK_END) != 0) return -1;
long size = ftell(input);
if (size < 0) return -1;
if (fseek(input, 0L, SEEK_SET) != 0) return -1;
return size;
}fseek and ftell are useful for seekable streams such as regular files, but not every stream is seekable: a pipe and many terminal inputs move only forward. Also remember buffering: output may wait in a user-space buffer until it is flushed, closed, or requires a visible prompt. Call fflush(stdout) only when interactive behavior needs the prompt to appear before more input is read.
Read one character at a time without confusing EOF
fgetc returns either the next byte value converted to unsigned char, or the special EOF value. Store that result in int, not char: an int can hold every byte value and the extra negative sentinel.
int copy_stream(FILE *source, FILE *destination) {
int character;
while ((character = fgetc(source)) != EOF) {
if (fputc(character, destination) == EOF) {
return 0;
}
}
if (ferror(source)) {
return 0;
}
return 1;
}The assignment must occur before the comparison. The loop body receives only a real byte. When the loop ends, feof is true only after a read attempt reaches end-of-file; it is not a condition to test before reading. Check ferror(source) if you need to distinguish a normal end from a read error.
Read lines with capacity-aware buffers
fgets accepts a character array and its capacity. It reads at most capacity - 1 characters, then writes a terminating \0 when it succeeds. A newline is kept if it fits. A long physical line can therefore arrive in several pieces.
char line[128];
int line_number = 0;
while (fgets(line, sizeof line, input) != NULL) {
line_number += 1;
printf("%d: %s", line_number, line);
}
if (ferror(input)) {
perror("reading input");
return 1;
}Use sizeof line only where line is an actual array in that scope. When passed to a function, an array parameter becomes a pointer and no longer carries its capacity. Pass the capacity separately and reject a zero capacity before calling fgets.
Use formatted conversion only with checked results
fscanf resembles scanf, but the stream is a file. Its return value is the number of successful assignments. Treat that value as part of the file format contract.
int score;
int total = 0;
int count = 0;
while (fscanf(input, "%d", &score) == 1) {
total += score;
count += 1;
}
if (!feof(input)) {
fprintf(stderr, "scores.txt contains a non-integer value
");
return 1;
}For user-created text formats, a line-oriented read followed by explicit parsing is often easier to diagnose than a long fscanf format. It gives your program the original line, a specific validation point, and a place to reject trailing unexpected characters.
Make a line parser own one complete record contract
A reliable file reader separates transport from meaning: fgets obtains one line fragment; a parser decides whether that line represents one valid record. The parser should accept an output pointer only after validating it and should reject trailing non-whitespace text.
#include <ctype.h>
#include <errno.h>
#include <limits.h>
#include <stdlib.h>
int parse_score_line(const char line[], int *score) {
char *end = NULL;
errno = 0;
long value = strtol(line, &end, 10);
while (isspace((unsigned char)*end)) end += 1;
if (line[0] == '\0' || *end != '\0' || errno == ERANGE ||
value < 0 || value > 100) {
return 0;
}
*score = (int)value;
return 1;
}The cast to unsigned char before isspace matters when char is signed and the input contains a byte above ASCII. A parser should report enough context at its call site—file path and line number—without mixing file I/O, error printing, and domain validation into one untestable loop.
Resource cleanup must survive every branch
As programs acquire more than one resource, early returns can accidentally skip cleanup. C does not have automatic destructors for ordinary FILE * objects. A focused goto cleanup is often clearer than duplicating cleanup in many branches, provided the label does only cleanup and the final status is preserved.
int transform_file(const char input_name[], const char output_name[]) {
int status = 1;
FILE *input = fopen(input_name, "r");
FILE *output = NULL;
if (input == NULL) goto cleanup;
output = fopen(output_name, "w");
if (output == NULL) goto cleanup;
if (!copy_stream(input, output)) goto cleanup;
status = 0;
cleanup:
if (output != NULL && fclose(output) == EOF) status = 1;
if (input != NULL && fclose(input) == EOF) status = 1;
return status;
}Do not use this pattern to jump into the middle of work. Use it to make ownership visible: initialize handles to NULL, acquire them in order, then release acquired handles in reverse order. In a program with only one file, an early return after a checked fclose remains perfectly readable.
Writing is also an operation that can fail
fprintf writes formatted text to a chosen stream. It returns a negative value on output failure. Check writing and closing when data matters: buffered output can report a late error when it is flushed by fclose.
FILE *output = fopen("report.txt", "w");
if (output == NULL) {
perror("report.txt");
return 1;
}
if (fprintf(output, "count=%d total=%d\n", count, total) < 0) {
perror("writing report.txt");
fclose(output);
return 1;
}
if (fclose(output) == EOF) {
perror("closing report.txt");
return 1;
}A program that should preserve an existing valuable file needs a stronger update strategy than opening it directly with "w": write a new temporary file, verify the result, then use a platform-appropriate replacement step. That design belongs to a later systems project, but the risk starts here.
argc counts argument strings; argv points to them
A hosted C program may use int main(int argc, char *argv[]). argc is the number of argument strings, and argv is an array of pointers to those strings. argv[0] conventionally identifies the invoked program; the first user argument is argv[1].
#include <stdio.h>
int main(int argc, char *argv[]) {
printf("program: %s\n", argv[0]);
for (int index = 1; index < argc; index++) {
printf("argument %d: %s\n", index, argv[index]);
}
return 0;
}cc -Wall -Wextra -Wpedantic -std=c17 report.c -o report
./report scores.txt --verbose gives argc == 3.
argv[2] is valid only when argc >= 3.
Arguments are text. Parse numbers, recognize options, and reject unsupported combinations.
The shell forms arguments before your C program starts
Your C program does not split a command line itself. The shell expands quotes, whitespace, wildcards, variables, and redirections, then starts the program with already separated strings. That is why a filename with spaces must be quoted at the shell, but arrives as one argv entry.
$ ./line_count "meeting notes.txt" --nonempty
argc = 3
argv[0] = "./line_count"
argv[1] = "meeting notes.txt"
argv[2] = "--nonempty"
/* Never build a shell command from argv and pass it to system().
Open argv[1] directly with fopen instead. */Make the command contract visible with a usage message
int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "Usage: %s INPUT_FILE\n", argv[0]);
return 2;
}
FILE *input = fopen(argv[1], "r");
if (input == NULL) {
perror(argv[1]);
return 1;
}
/* Process the input stream. */
return fclose(input) == EOF ? 1 : 0;
}Usage errors are different from a valid request that cannot be completed. This example returns 2 for invalid command syntax and 1 for an operating-system or file failure. The exact convention is yours to document; the important part is that scripts can distinguish success from failure.
See what C receives after the shell splits a command
Enter a command shape, predict argc and argv, then run the contract check. This is a teaching model; your terminal shell performs the actual quoting and expansion.
Parse numeric arguments, then validate the whole string
Command-line arguments are never already integers. Avoid atoi when invalid input matters because it provides no reliable error signal. strtol reports where parsing stopped and makes a range check possible.
#include <errno.h>
#include <limits.h>
#include <stdlib.h>
int parse_count(const char text[], int *result) {
char *end = NULL;
errno = 0;
long value = strtol(text, &end, 10);
if (text[0] == '\0' || *end != '\0' || errno == ERANGE ||
value < 0 || value > INT_MAX) {
return 0;
}
*result = (int)value;
return 1;
}The parser rejects an empty string, text such as 12cats, a number that overflowed long, and a value outside the chosen int contract. A caller must still check that result is a valid non-NULL output pointer before passing it to a production version of this function.
Treat flags and paths as a small language
Even a tiny command has grammar. State it in the usage line, then parse from left to right. Reject unknown flags and ambiguous combinations rather than silently guessing. The simple loop below keeps an option parser visible before a later lesson introduces a dedicated option-parsing library.
int nonempty_only = 0;
const char *path = NULL;
for (int index = 1; index < argc; index++) {
if (strcmp(argv[index], "--nonempty") == 0) {
nonempty_only = 1;
} else if (path == NULL) {
path = argv[index];
} else {
fprintf(stderr, "unexpected argument: %s\n", argv[index]);
return 2;
}
}
if (path == NULL) {
fprintf(stderr, "Usage: %s FILE [--nonempty]\n", argv[0]);
return 2;
}Options that begin with - and paths that happen to begin with - eventually require a convention such as -- to mark the end of options. Document that convention before accepting arbitrary user paths. This is a design decision, not a syntax detail.
Independent lab: a line-counting command
Build line_count.c locally. It accepts one required filename and an optional --nonempty flag. It prints a stable, script-friendly summary.
$ cc -Wall -Wextra -Wpedantic -std=c17 line_count.c -o line_count
$ ./line_count notes.txt
notes.txt: 42 lines
$ ./line_count notes.txt --nonempty
notes.txt: 37 non-empty linesstderr.fopen(argv[1], "r"), test for NULL, and include the path in perror.fgetc stored in int; decide and document whether a final unterminated line counts.ferror, then fclose, before printing a success result.Worked design: a resilient score-report CLI
Put the lesson together with a program that reads one integer score per line and writes a summary. Its boundaries are visible: arguments decide the file and passing mark, stream code reads lines, a parser validates each line, domain logic calculates the result, and output reports a stable summary.
Usage: ./score_report SCORES_FILE PASSING_SCORE
1. Check argc == 3 before reading argv[1] or argv[2].
2. Parse PASSING_SCORE with strtol; require 0 through 100.
3. Open SCORES_FILE with "r"; use perror(argv[1]) if it fails.
4. For each fgets line:
- reject an overlong line or a line that parse_score_line rejects;
- increment count, sum, and passing count only after validation.
5. Check ferror(input), then fclose(input).
6. Print exactly: records=N average=X.XX passing=M
to stdout.
7. Return 0 only after every required operation succeeds.Test this tool with an empty file, one valid line without a final newline, a blank line, an out-of-range score, a non-number, a missing path, a directory path, and a file too long for your chosen buffer. The happy path shows that code runs; these cases show that its contract holds.
Lesson review
fopenreturnsNULLon failure. Test it before every operation through the resultingFILE *.- Use
"r"to read,"w"only when truncation is intended, and"a"for deliberate append-only output. - Store
fgetcinint, compare it withEOF, and checkferrorafter the loop. fgetsneeds a real capacity, retains a fitting newline, and can return a long line in pieces.- Check writes and the final
fclose; a successfulfopenrequires cleanup on every success path. - Keep data on
stdoutand diagnostics onstderrso scripts can redirect and compose your tool safely. - Use line-oriented reads plus a parser when you need actionable validation, exact error location, and rejection of trailing junk.
- Validate
argcbefore indexingargv; every argument begins as text and needs an explicit parser and domain check. - The shell creates the argument strings before C starts. Quote paths in the shell, but never send an argument back through a shell with
system.