Systems Software Engineer

Using fgets and strtol Safely in C

A practical guide to reading integer input safely, detecting invalid characters, handling overflow, enforcing ranges, and building reusable input functions in C.

Using fgets and strtol Safely in C

Reading an integer from a terminal appears simple, but user input is never guaranteed to be valid. A dependable C program must distinguish between valid numbers, malformed input, overflow, extra characters, empty lines, and values outside an acceptable range.

One of the safest and most reusable approaches is to separate input into two operations:

  1. Use fgets to read an entire line as text.
  2. Use strtol to deliberately convert that text into an integer.

This separation gives the program an opportunity to inspect the complete input before accepting it.

Why Input Is More Difficult Than It Looks

Suppose a program asks the user to enter their age. The programmer may expect:

Output
25

The user might instead enter:

  • -4, which is an integer but may be outside the permitted range
  • 25abc, which begins with digits but contains invalid trailing characters
  • hello, which contains no integer
  • An empty line
  • A number too large to fit in the destination type
  • A line longer than the input buffer

The computer receives all these inputs as characters. The program must decide whether those characters represent an acceptable value.

Keyboard input begins as text. Conversion should occur only after the program captures and validates that text.

Why Not Rely Only on scanf?

A beginner may initially write:

C
int age;

printf("Enter your age: ");
scanf("%d", &age);

This can work for valid input, but robust validation and recovery are more complicated. When conversion fails, invalid characters may remain in the input stream and interfere with later reads.

If the user enters:

Output
abc

scanf cannot convert those characters using %d. Unless the program deliberately removes the invalid input, the next operation may encounter the same characters again.

The issue is not that scanf can never be used safely. The issue is that line-oriented input with fgets often makes validation and recovery easier to understand.

The safer sequence is:

  1. Capture the complete line.
  2. Convert the stored text.
  3. Inspect where conversion stopped.
  4. Reject invalid or out-of-range input.
  5. Store the result only after every check succeeds.

The fgets Mental Model

fgets reads characters from a stream and stores them in a character array.

C
fgets(buffer, buffer_size, stdin);

Its three arguments are:

  • buffer: the destination character array
  • buffer_size: the total capacity of that array
  • stdin: the standard input stream

A basic example is:

C
#include <stdio.h>

char buffer[100];

if (fgets(buffer, sizeof(buffer), stdin) == NULL) {
    fprintf(stderr, "Input could not be read.\n");
}

The return value must always be checked. NULL can represent end-of-file or an input error.

What fgets stores

If the user types 42 and presses Enter, the array usually contains:

Output
'4'  '2'  '\n'  '\0'
  • '4' and '2' are the entered characters.
  • '\n' represents the Enter key.
  • '\0' terminates the C string.

The newline matters because validation should normally treat it as the end of the user's meaningful input.

Detecting an oversized line

If the user enters more characters than the array can hold, fgets stores only part of the line. The remaining characters stay in stdin.

The program can check whether the captured string contains a newline:

C
#include <string.h>

if (strchr(buffer, '\n') == NULL) {
    /* The complete line may not have fit. */
}

When a line is too long, discard its remainder before requesting another value:

C
#include <stdio.h>

static void discard_remaining_input(void)
{
    int character;

    while (
        (character = getchar()) != '\n' &&
        character != EOF
    ) {
        /* Intentionally discard each character. */
    }
}

Using int for the result of getchar is important because it must represent every possible unsigned-character value as well as the special EOF value.

The strtol Mental Model

The name strtol means string to long.

It examines a string and attempts to convert its initial numeric portion into a value of type long.

C
char *end = NULL;
long value = strtol(buffer, &end, 10);

The arguments mean:

  • buffer: the text to convert
  • &end: where strtol stores the stopping position
  • 10: use decimal notation

The end pointer is essential because it identifies the first character that was not part of the conversion.

For example:

  • Input "42\n" produces 42, with end pointing at the newline.
  • Input "42abc\n" produces 42, with end pointing at 'a'.
  • Input "hello\n" performs no conversion, with end equal to buffer.
  • Input "-15\n" produces -15, with end pointing at the newline.

A returned value of zero is not sufficient evidence of success. Both valid input such as "0" and invalid input such as "hello" may result in a numeric value of zero. The end pointer determines whether any digits were converted.

Validate Every Conversion Deliberately

A dependable conversion should perform its checks in a predictable order.

Confirm that input was read

C
if (fgets(buffer, sizeof(buffer), stdin) == NULL) {
    /* End-of-file or an input error occurred. */
}

Confirm that the complete line was captured

C
if (strchr(buffer, '\n') == NULL) {
    discard_remaining_input();
    /* Reject the oversized line. */
}

Clear errno before conversion

C
#include <errno.h>

errno = 0;
value = strtol(buffer, &end, 10);

This ensures that a later ERANGE check describes the current conversion rather than an earlier library operation.

Confirm that digits were converted

C
if (end == buffer) {
    /* The input did not begin with an integer. */
}

Detect overflow or underflow

C
if (errno == ERANGE) {
    /* The value cannot be represented as long. */
}

Reject invalid trailing characters

Spaces and tabs may be permitted after the integer, but other characters should be rejected.

C
while (*end == ' ' || *end == '\t') {
    end++;
}

if (*end != '\n' && *end != '\0') {
    /* Unexpected trailing characters remain. */
}

This accepts 42 followed by spaces, while rejecting 42abc.

Confirm that the value fits in int

strtol returns long. If the destination is int, validate its range before casting.

C
#include <limits.h>

if (value < INT_MIN || value > INT_MAX) {
    /* The value cannot be represented as int. */
}

Do not cast before checking. A premature conversion can produce a result that no longer represents the entered value.

Build a Reusable read_int Function

Instead of repeating validation throughout a program, place it behind a clear interface. A Boolean success/failure result is tempting, but it hides a distinction the caller often needs: malformed text can be retried, while end-of-file and stream errors should stop the loop.

Use an explicit status type:

C
#ifndef READ_INT_H
#define READ_INT_H

enum read_int_status {
    READ_INT_OK,
    READ_INT_INVALID,
    READ_INT_END,
    READ_INT_IO_ERROR
};

enum read_int_status read_int(
    const char *prompt,
    int *result
);

#endif

This interface communicates four outcomes:

  • READ_INT_OK: a complete, valid int was stored
  • READ_INT_INVALID: input was received but was malformed, oversized, or out of range
  • READ_INT_END: the stream reached end-of-file before supplying another value
  • READ_INT_IO_ERROR: the stream itself failed

The implementation prints an optional prompt, captures one logical line, validates the complete conversion, and modifies the output only on success.

C
#include "read_int.h"

#include <ctype.h>
#include <errno.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define INPUT_BUFFER_SIZE 128

static void discard_remaining_input(void)
{
    int character;

    while (
        (character = getchar()) != '\n' &&
        character != EOF
    ) {
        /* Intentionally discard each character. */
    }
}

enum read_int_status read_int(
    const char *prompt,
    int *result
)
{
    char buffer[INPUT_BUFFER_SIZE];
    char *end = NULL;
    long value;

    if (result == NULL) {
        return READ_INT_INVALID;
    }

    if (prompt != NULL) {
        printf("%s", prompt);
        fflush(stdout);
    }

    if (fgets(buffer, sizeof(buffer), stdin) == NULL) {
        if (feof(stdin)) {
            return READ_INT_END;
        }

        return READ_INT_IO_ERROR;
    }

    /* A final line may legitimately end at EOF without a newline. */
    if (strchr(buffer, '\n') == NULL && !feof(stdin)) {
        discard_remaining_input();
        return READ_INT_INVALID;
    }

    errno = 0;
    value = strtol(buffer, &end, 10);

    if (end == buffer) {
        return READ_INT_INVALID;
    }

    if (errno == ERANGE) {
        return READ_INT_INVALID;
    }

    while (*end != '\0' && isspace((unsigned char)*end)) {
        end++;
    }

    if (*end != '\0') {
        return READ_INT_INVALID;
    }

    if (value < INT_MIN || value > INT_MAX) {
        return READ_INT_INVALID;
    }

    *result = (int)value;

    return READ_INT_OK;
}

The cast passed to isspace is deliberate. Character-classification functions require either EOF or a value representable as unsigned char; passing a negative plain char can invoke undefined behavior.

The output variable is modified only after every validation check succeeds. Failed input therefore cannot partially change the caller's state. The !feof(stdin) condition also avoids incorrectly rejecting a complete final line that ends at EOF without a newline.

Use the Function From a Program

C
#include "read_int.h"

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int number;
    enum read_int_status status;

    status = read_int("Enter an integer: ", &number);

    switch (status) {
    case READ_INT_OK:
        printf("You entered: %d\n", number);
        return EXIT_SUCCESS;

    case READ_INT_INVALID:
        fprintf(stderr, "Invalid integer input.\n");
        return EXIT_FAILURE;

    case READ_INT_END:
        fprintf(stderr, "No input was available.\n");
        return EXIT_FAILURE;

    case READ_INT_IO_ERROR:
        perror("stdin");
        return EXIT_FAILURE;
    }

    return EXIT_FAILURE;
}

Compile both translation units:

BASH
clang \
  -std=c17 \
  -Wall \
  -Wextra \
  -Wpedantic \
  -Wconversion \
  -Wshadow \
  -Wformat=2 \
  -Werror \
  main.c \
  read_int.c \
  -o safe_input

Run the program:

BASH
./safe_input

An invalid and valid interaction might look like this:

Output
$ ./safe_input
Enter an integer: hello
Invalid integer input.

$ ./safe_input
Enter an integer: 24
You entered: 24

Add Application-Specific Range Validation

An integer can be syntactically valid but still unacceptable to a particular operation.

If a program asks how many numbers the user wants to enter, it might accept only values from 1 through 100.

C
#include "read_int.h"

#include <stdio.h>

enum read_int_status read_int_in_range(
    const char *prompt,
    int minimum,
    int maximum,
    int *result
)
{
    int value;
    enum read_int_status status;

    if (result == NULL || minimum > maximum) {
        return READ_INT_INVALID;
    }

    for (;;) {
        status = read_int(prompt, &value);

        if (status == READ_INT_END ||
            status == READ_INT_IO_ERROR) {
            return status;
        }

        if (status == READ_INT_INVALID) {
            printf("Please enter a valid integer.\n");
            continue;
        }

        if (value < minimum || value > maximum) {
            printf(
                "Enter a value from %d through %d.\n",
                minimum,
                maximum
            );
            continue;
        }

        *result = value;
        return READ_INT_OK;
    }
}

Example usage:

C
int count;
enum read_int_status status;

status = read_int_in_range(
    "How many numbers? ",
    1,
    100,
    &count
);

if (status != READ_INT_OK) {
    /* EOF and stream errors stop the operation. */
}

This separation gives the functions distinct responsibilities:

  • read_int decides whether the text represents an int and reports why reading stopped.
  • read_int_in_range decides whether that valid integer is acceptable to the application.

Most importantly, read_int_in_range does not retry forever after end-of-file. Recoverable validation failures and terminal stream conditions follow different control paths.

Test More Than the Successful Path

An input function proves its value through failure cases.

Test at least:

  • 42: valid positive integer
  • -17: valid negative integer when permitted
  • 42 followed by spaces: valid when trailing whitespace is allowed
  • 12abc: invalid trailing characters
  • hello: no conversion
  • 999999999999999999999: likely outside the range of long
  • An empty line: no integer
  • A line larger than the input buffer: must be rejected and discarded
  • 2147483648 on a system with 32-bit int: outside the range of int
  • End-of-file: the read operation cannot provide a new value
  • A valid final line without a newline before end-of-file
  • A simulated stream error when the test environment supports one

Testing should also verify that an invalid attempt does not change the caller's existing output value.

Common Mistakes to Avoid

Ignoring the result of fgets

C
fgets(buffer, sizeof(buffer), stdin);

/* The program incorrectly assumes input was read. */

Always test for NULL.

Discarding the end pointer

C
value = strtol(buffer, NULL, 10);

Passing NULL prevents the program from determining whether trailing characters remained.

Forgetting to clear errno

Set errno = 0 immediately before strtol. Otherwise, an earlier operation may have left an unrelated error value behind.

Casting before validating

C
int number = (int)strtol(
    buffer,
    &end,
    10
);

Store the result in long, validate it, compare it with INT_MIN and INT_MAX, and only then cast.

Leaving an oversized line in stdin

If the complete line does not fit, the next fgets may read leftover characters instead of new input. Discard the remainder before continuing.

Modifying output before validation finishes

Do not write to the caller's result until every check succeeds. This preserves a clear failure invariant.

The Safe-Input Checklist

When combining fgets and strtol, verify that:

  • The destination buffer has a known capacity.
  • The result of fgets is checked.
  • Oversized lines are detected and discarded.
  • errno is cleared before conversion.
  • The end pointer is compared with the buffer.
  • ERANGE is checked.
  • Unexpected trailing characters are rejected.
  • The long result is checked against the destination type's range.
  • The caller's output is stored only after complete validation.
  • Invalid input, end-of-file, and stream errors have distinguishable results.
  • Valid, invalid, boundary, oversized, and end-of-file cases are tested.

Input Validation Is Interface Design

Safe input handling is not only about choosing a library function. It is about designing a boundary between untrusted text and trusted program state.

fgets captures the input record. strtol converts deliberately and reports where conversion stopped. Range checks protect the destination type. Explicit status values preserve the difference between retryable input and a stream that has ended or failed. Output parameters remain unchanged until validation succeeds. Tests exercise both ordinary and hostile input.

This same discipline applies beyond interactive programs. Configuration parsers, command-line tools, network services, file readers, compilers, and runtime systems all receive data that must be validated before it becomes internal state.

The strongest implementation is therefore not the one that accepts 42. It is the one that can explain—and correctly handle—every important way the input might not be 42.