This started with a fairly pointless argument.

A friend said that old programming languages only feel fast because programs written in them were simpler. Modern languages, according to him, are slower mostly because modern software does much more.

That sounded reasonable. It also sounded like something people repeat without ever checking.

So instead of opening another benchmark page, I decided to waste a weekend properly.

The idea was simple: build exactly the same console program in four languages from different eras and keep the algorithm deliberately boring.

The program reads a text file containing integers, ignores invalid lines, calculates the minimum and maximum values, computes the arithmetic mean, counts values above a configurable threshold and produces a small checksum so that a compiler cannot conveniently optimize half of the work away.

Nothing graphical. No database. No network. No frameworks.

That matters because the moment a framework enters the experiment, you are often benchmarking ecosystems rather than languages.

The test file contained one million lines. Most contained normal signed integers. A few thousand contained broken data: empty lines, random characters and values with extra spaces.

Why include bad input?

Because real programs rarely get the clean input shown in algorithm textbooks. Parsing and error handling are part of the work too.

The four versions were written for QB64-style BASIC, C, Free Pascal and Python 3. I tried to keep the internal logic similar, although forcing identical code structure would have made the comparison less useful. Languages have different natural ways of expressing the same operation.

That became the first lesson before benchmarking had even started.

Equal algorithm does not mean equal program.

The C version became longer much faster than expected

C was the version I expected to enjoy writing most.

The task fits C nicely. Open a file, allocate a buffer, parse lines, update a few counters, print the result. There is almost nothing between the code and the operating system.

Then error handling arrived.

A short program slowly turned into a collection of tiny decisions. What if fopen fails? What if strtol parses only half the line? What happens on overflow? Should whitespace after the number be accepted? Is the accumulator wide enough? What happens if the file contains no valid values?

None of these problems is difficult. Together they are surprisingly effective at turning thirty lines into something considerably larger.

Here is the core version I ended up with.

/* C11 */

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

typedef struct {
    int64_t count;
    int64_t invalid;
    int64_t above_threshold;
    int64_t minimum;
    int64_t maximum;
    long double sum;
    uint64_t checksum;
} Stats;

static int parse_integer(const char *line, int64_t *value)
{
    char *end;
    long long parsed;

    errno = 0;
    parsed = strtoll(line, &end, 10);

    if (line == end)
        return 0;

    if (errno == ERANGE)
        return 0;

    while (*end != '\0') {
        if (*end == '\n' || *end == '\r') {
            end++;
            continue;
        }

        if (!isspace((unsigned char)*end))
            return 0;

        end++;
    }

    *value = (int64_t)parsed;
    return 1;
}

static void update_stats(Stats *stats, int64_t value, int64_t threshold)
{
    if (stats->count == 0) {
        stats->minimum = value;
        stats->maximum = value;
    } else {
        if (value < stats->minimum)
            stats->minimum = value;

        if (value > stats->maximum)
            stats->maximum = value;
    }

    if (value > threshold)
        stats->above_threshold++;

    stats->sum += (long double)value;
    stats->count++;

    stats->checksum ^= (uint64_t)value
        + 0x9e3779b97f4a7c15ULL
        + (stats->checksum << 6)
        + (stats->checksum >> 2);
}

int main(int argc, char **argv)
{
    FILE *file;
    char buffer[256];
    int64_t threshold;
    Stats stats = {0};

    if (argc != 3) {
        fprintf(stderr, "Usage: %s file threshold\n", argv[0]);
        return EXIT_FAILURE;
    }

    {
        char *end;
        errno = 0;
        threshold = strtoll(argv[2], &end, 10);

        if (errno == ERANGE || argv[2] == end || *end != '\0') {
            fprintf(stderr, "Invalid threshold\n");
            return EXIT_FAILURE;
        }
    }

    file = fopen(argv[1], "r");

    if (!file) {
        perror("fopen");
        return EXIT_FAILURE;
    }

    while (fgets(buffer, sizeof(buffer), file)) {
        int64_t value;

        if (!parse_integer(buffer, &value)) {
            stats.invalid++;
            continue;
        }

        update_stats(&stats, value, threshold);
    }

    if (ferror(file)) {
        fprintf(stderr, "Error while reading input file\n");
        fclose(file);
        return EXIT_FAILURE;
    }

    fclose(file);

    if (stats.count == 0) {
        printf("No valid values\n");
        printf("Invalid: %lld\n", (long long)stats.invalid);
        return EXIT_SUCCESS;
    }

    printf("Valid: %lld\n", (long long)stats.count);
    printf("Invalid: %lld\n", (long long)stats.invalid);
    printf("Minimum: %lld\n", (long long)stats.minimum);
    printf("Maximum: %lld\n", (long long)stats.maximum);
    printf("Mean: %.3Lf\n", stats.sum / stats.count);
    printf("Above threshold: %lld\n",
           (long long)stats.above_threshold);
    printf("Checksum: %llu\n",
           (unsigned long long)stats.checksum);

    return EXIT_SUCCESS;
}

The interesting part was not that C produced fast native code. Nobody needs a weekend experiment to discover that.

What surprised me was how much of the source code existed only to make incorrect states explicit.

C kept asking questions that Python would later quietly answer for me.

Sometimes that felt annoying. Sometimes it felt excellent.

After debugging an intentionally damaged input file, I actually started liking the verbosity. There was almost nowhere for an assumption to hide.

But there was a cost: C was also the implementation where a tiny change made me reread the largest amount of surrounding code.

That is difficult to put into a benchmark number, but while programming it was impossible not to notice.

Pascal felt much less ancient than I remembered

Pascal was supposed to be the nostalgic part of the experiment.

Instead it became the first surprise.

My memories of Pascal were mostly school exercises, arrays, loops and programs that calculated something about triangles. That mental image turned out to be fairly misleading when using a modern Pascal compiler.

The implementation was verbose, yes, but the verbosity had a strange property: most of it was easy to scan.

Variables had obvious types. Blocks had obvious boundaries. File processing looked like file processing instead of a chain of abstractions.

The code did not feel modern, but it also did not feel primitive.

There is a difference.

The second unexpected thing was how difficult it was to accidentally make the program mysterious. Pascal practically encouraged me to name intermediate states.

Modern code often rewards compact expressions. Pascal kept pulling the code in the opposite direction.

At first this annoyed me.

Then I returned to the program the next morning and understood almost immediately what every part was doing.

Have you ever opened your own clever code six months later and wondered which idiot wrote it?

That effect was noticeably weaker here.

Performance was also much closer to the native C implementation than my old mental model expected. That should not be shocking because both can produce native machine code, but old language somehow becomes slow language in the brain if you have not touched it for years.

The compiler also caught several mistakes before execution.

This sounds trivial until you switch between four languages in one weekend. When the same bug appears in different implementations, you suddenly notice exactly which language allows it to survive longest.

Pascal was particularly good at making boring mistakes boring to fix.

Maybe that is not a benchmark category, but perhaps it should be.

BASIC was supposed to lose

BASIC entered the test almost as a joke.

If you grew up seeing old listings full of line numbers and GOTO, the language carries a very specific image. It looks like something that belongs next to a beige CRT monitor.

Modern BASIC dialects are a different animal.

The version I used still had the straightforward feeling I expected, but structured loops, procedures and proper variables removed most of the chaos associated with very old BASIC programs.

The implementation became larger than Python but remained remarkably easy to follow.

There was an interesting side effect.

Because the language encouraged simple operations, the BASIC version accumulated almost no abstraction. There were no helper layers worth discussing. No clever iterator. No generic parser architecture. No attempt to turn a weekend utility into a reusable data-processing framework.

It simply opened a file and did the job.

This made me slightly uncomfortable because modern programming habits kept suggesting improvements.

Should parsing become its own module?

Should statistics be represented by an object?

Should input handling support streams?

Then another thought appeared.

Why?

This program had exactly one job.

A slightly embarrassing amount of software complexity probably starts with a developer trying to answer imaginary future requirements.

BASIC did not magically prevent overengineering, of course. It merely made overengineering feel more ridiculous.

For a small utility, that turned out to be useful.

The runtime result also refused to fit the cartoon version of the experiment where old interpreted BASIC crawls while everything else flies. With a modern compiled dialect, the gap depended heavily on parsing and I/O rather than on arithmetic itself.

That forced me to change the question.

I had started by asking which language was fastest.

Now I was asking what exactly I was measuring.

Python won the first hour and lost some points later

Python was almost unfair at the beginning.

The first working version appeared before the C implementation had finished arguing with malformed input.

The whole program fits comfortably on one screen if you do not obsess over validation.

A more careful version is still compact.

# Python 3

from __future__ import annotations

import sys
from dataclasses import dataclass
from pathlib import Path


MASK64 = (1 << 64) - 1
MAGIC = 0x9E3779B97F4A7C15


@dataclass
class Stats:
    count: int = 0
    invalid: int = 0
    above_threshold: int = 0
    minimum: int | None = None
    maximum: int | None = None
    total: int = 0
    checksum: int = 0

    def update(self, value: int, threshold: int) -> None:
        if self.minimum is None or value < self.minimum:
            self.minimum = value

        if self.maximum is None or value > self.maximum:
            self.maximum = value

        if value > threshold:
            self.above_threshold += 1

        self.total += value
        self.count += 1

        self.checksum ^= (
            value
            + MAGIC
            + ((self.checksum << 6) & MASK64)
            + (self.checksum >> 2)
        ) & MASK64

        self.checksum &= MASK64


def parse_integer(line: str) -> int | None:
    stripped = line.strip()

    if not stripped:
        return None

    try:
        return int(stripped, 10)
    except ValueError:
        return None


def analyze(path: Path, threshold: int) -> Stats:
    stats = Stats()

    with path.open(
        mode="r",
        encoding="utf-8",
        errors="strict",
        buffering=1024 * 1024,
    ) as source:
        for line in source:
            value = parse_integer(line)

            if value is None:
                stats.invalid += 1
                continue

            stats.update(value, threshold)

    return stats


def print_report(stats: Stats) -> None:
    if stats.count == 0:
        print("No valid values")
        print(f"Invalid: {stats.invalid}")
        return

    mean = stats.total / stats.count

    print(f"Valid: {stats.count}")
    print(f"Invalid: {stats.invalid}")
    print(f"Minimum: {stats.minimum}")
    print(f"Maximum: {stats.maximum}")
    print(f"Mean: {mean:.3f}")
    print(f"Above threshold: {stats.above_threshold}")
    print(f"Checksum: {stats.checksum}")


def main(argv: list[str]) -> int:
    if len(argv) != 3:
        print(
            f"Usage: {argv[0]} file threshold",
            file=sys.stderr,
        )
        return 1

    path = Path(argv[1])

    try:
        threshold = int(argv[2], 10)
    except ValueError:
        print("Invalid threshold", file=sys.stderr)
        return 1

    if not path.is_file():
        print("Input file does not exist", file=sys.stderr)
        return 1

    try:
        stats = analyze(path, threshold)
    except OSError as error:
        print(f"I/O error: {error}", file=sys.stderr)
        return 1
    except UnicodeError as error:
        print(f"Encoding error: {error}", file=sys.stderr)
        return 1

    print_report(stats)
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv))

This version has more structure than strictly necessary because I wanted the implementations to survive bad input and remain readable.

Still, look at what Python gives away for free.

Integers do not suddenly overflow at 32 or 64 bits. File lifetime is handled by the context manager. Converting a string into an integer takes one obvious operation. There is no manual buffer length to think about.

That explains part of Python productivity better than arguments about syntax ever could.

The language removes decisions.

For a student project or an internal tool, that can be incredibly valuable.

But the benchmark also exposed the other side.

Inside the hot loop, every convenience has machinery behind it. Each line is a Python object. Integer operations involve Python objects. Method calls are Python calls. Exceptions and dynamic dispatch exist whether or not the algorithm itself is trivial.

This was the only implementation where looking at the source alone gave a completely misleading impression of how much work the runtime was actually doing.

Five visible operations can represent a surprisingly large pile of invisible operations.

That does not make Python bad.

It makes source-code complexity and execution complexity two different things.

I knew this before the experiment. Seeing four implementations of exactly the same loop made it much harder to ignore.

The benchmark became less scientific as it became more useful

Originally I planned to declare a winner based on runtime.

That idea lasted about twenty minutes.

A fair comparison immediately becomes annoying.

Do you count compiler time?

Python has almost none from the user's point of view. C and Pascal do.

Do you count interpreter startup?

What about filesystem cache?

Should the first run be discarded?

Do we measure peak resident memory or allocated memory inside the runtime?

Should source size include comments?

Should the BASIC runtime bundled into an executable count toward program size?

And what optimization level is fair for C?

The more carefully I defined the test, the less useful one final score became.

So I stopped trying to create one.

Instead, every implementation was run repeatedly against the same generated input. The first run was discarded. Input was identical. Output and checksum had to match before timing results were accepted.

That checksum check saved the experiment once.

The fastest version of a program is surprisingly easy to create if it accidentally does less work.

At one point the Pascal implementation appeared to beat everything by an absurd margin. For roughly thirty seconds this was exciting.

Then I noticed that malformed lines caused a control-flow mistake and part of the dataset was effectively skipped.

So much for the breakthrough in compiler technology.

Once the output validation was added, the magical optimization disappeared.

This is probably my favourite result of the whole experiment.

Benchmarks need correctness tests.

Otherwise you can spend an evening carefully measuring a bug.

Runtime was predictable. Memory was more interesting

The broad runtime order did not contain a revolution.

Native compiled implementations were the strongest when the loop became CPU-heavy. Python paid a noticeable runtime cost for its higher-level execution model. BASIC depended strongly on the implementation and compilation mode.

Nothing there made me fall out of the chair.

Memory told a more interesting story.

The C implementation could operate with a tiny and predictable working set because the input was processed line by line into primitive numeric values.

Pascal behaved similarly.

Python could also stream the input and therefore avoided storing a million values, but the runtime itself carries significantly more machinery before the first input line is processed.

That difference matters for tiny command-line tools more than for a large server where the interpreter overhead becomes a small fraction of total memory.

This also made source-code size look almost comical.

Python had the shortest path from idea to working program, while C had the shortest conceptual distance between the values in my algorithm and their representation in memory.

Those are completely different kinds of simplicity.

Which one do you care about?

For a script executed twice a month, probably the first.

For a utility started ten thousand times across small containers or embedded devices, possibly the second.

Programming-language arguments often become pointless because people discuss simplicity without saying which simplicity they mean.

Binary size produced the funniest result

This was the measurement that changed how I looked at the entire comparison.

With compiled languages, it is easy to ask how large the executable is.

With Python, the question becomes strangely philosophical.

Is the Python file the program?

Then it is tiny.

Is the interpreter part of the program?

If Python is already installed, perhaps not.

If I need to send the utility to a random Windows machine, suddenly the runtime matters a lot.

The same problem appeared with BASIC depending on how the executable was produced.

So binary size was not really a language property. It was a deployment-property measurement wearing a language benchmark costume.

That sounds obvious now.

It was not obvious when I started.

The smallest source did not necessarily create the smallest deployable thing. The fastest executable did not necessarily produce the easiest installation. The language with the largest runtime could still be the best choice on a machine where that runtime already exists.

Context kept ruining every clean conclusion.

Which, unfortunately, is how engineering usually works.

The real difference was how each language made me think

After spending enough time staring at the same algorithm, syntax stopped being the interesting part.

Each language kept pulling attention toward a different problem.

C made memory representation and failure cases visible.

Pascal made program structure visible.

BASIC kept the task itself visible.

Python kept the intention visible.

This affected the code I wrote.

In C, I thought about bytes before thinking about abstractions.

In Python, I thought about data transformations before thinking about storage.

Pascal encouraged explicit intermediate states.

BASIC made me question whether several abstractions were necessary at all.

That was the result I did not expect.

I started with four tools and one algorithm.

Somewhere in the middle it became four slightly different ways of looking at the same problem.

And this probably explains why experienced developers can disagree so violently about programming languages without either side being completely wrong.

A language is not only a notation for telling a computer what to do.

It constantly decides which problems are cheap enough for the programmer to notice.

Would I actually use BASIC or Pascal today?

Pascal, yes.

Not everywhere, obviously.

For a small native utility, teaching, retro projects or simply exploring systems programming without immediately jumping into C or C++, modern Pascal is far more usable than its reputation suggests.

BASIC is harder for me to imagine in a new production system, but the experiment changed my attitude toward it.

There is something healthy about a language where a twenty-minute utility can remain a twenty-minute utility.

Python is still what I would probably reach for first for this exact program if the script lived on my own machine.

Development speed wins.

If the utility needed to be distributed as a tiny standalone executable, run constantly, live for years and have minimal runtime dependencies, C or Pascal suddenly becomes much more attractive.

And that is the boring conclusion I was trying not to reach.

There is no winner.

But there are definitely bad matches between languages and problems.

The interesting question is not whether C is faster than Python or whether Pascal is obsolete.

The more useful question is this:

What are you paying for when you choose a language?

Sometimes you pay in CPU time.

Sometimes in memory.

Sometimes in deployment complexity.

Sometimes in programmer time.

And sometimes you pay six months later when you reopen your own source code and discover that the clever version you loved is now archaeological material.

After writing the same boring program four times, programmer time is the metric I would measure much more carefully next time.