28 min read

> "A program without files is like a conversation without memory — brilliant in the moment, but gone by morning."

Chapter 13: Files and I/O — Text Files, Typed Files, and Untyped Files

"A program without files is like a conversation without memory — brilliant in the moment, but gone by morning."

Every program we have written so far has suffered from a kind of amnesia. Run the GradeBook, enter fifty students, calculate their averages — and the moment you press Enter for the last time, everything vanishes. Close the terminal and the data evaporates as though it never existed. The variables that held those names and scores lived in RAM, and RAM is volatile by definition. Pull the plug, end the process, and the bits scatter.

Files change everything. A file is a named sequence of bytes stored on a persistent medium — a hard drive, an SSD, a USB stick. Data written to a file survives the program that created it. It survives a reboot. It can be read by a completely different program, on a completely different machine, years later. Files are how programs remember.

In this chapter we explore Pascal's rich file I/O system. We will work with text files (human-readable, line-oriented), typed files (binary files whose structure mirrors a Pascal type), and untyped files (raw byte streams for maximum flexibility). By the end, you will be able to persist any data your programs produce, read it back reliably, and handle the inevitable errors — missing files, permission problems, corrupted data — with grace.

This is the chapter where PennyWise stops being a toy and becomes a tool. When you can save expenses to disk and load them the next day, the program has real utility. And when Crypts of Pascalia can save the player's progress, the game becomes something worth playing across multiple sessions.

Let us begin.

🔗 Spaced Review — From Chapter 11: What is the difference between a record and an array? A record groups fields of different types under one name (like a student's name, grade, and percentage). An array groups elements of the same type in indexed positions. Records describe the structure of a single entity; arrays hold collections of entities. In this chapter, we will combine both: arrays of records stored in files.

🔗 Spaced Review — From Chapter 9: What happens when you access an array element beyond its declared bounds? With {$R+} (range checking enabled), you get a runtime error. Without range checking, you silently read or write memory that does not belong to the array — a dangerous bug. The same caution applies to file operations: reading past the end of a file is just as dangerous as reading past the end of an array.


13.1 Why Files?

Before we dive into syntax, let us establish why files matter and what kinds of problems they solve.

The Persistence Problem

Consider the lifecycle of a variable:

Program starts  →  Variable declared  →  Value assigned  →  Program ends  →  Variable destroyed

Everything between "Program starts" and "Program ends" is the variable's lifetime. For a local variable inside a procedure, the lifetime is even shorter — just the duration of that procedure call. This is fine for intermediate calculations, but it is catastrophic for data that users expect to keep.

Think about the applications you use every day. A word processor saves documents. A spreadsheet saves worksheets. A game saves progress. A database saves records. All of these use files (or file-like abstractions) to persist data beyond a single execution.

What a File Actually Is

At the operating system level, a file is:

  1. A name — a path in the filesystem (e.g., C:\data\expenses.dat or /home/user/expenses.dat)
  2. A sequence of bytes — the actual content, stored on persistent media
  3. Metadata — size, creation date, permissions, and other attributes managed by the OS

Pascal provides three abstractions over this raw byte sequence:

File Type Pascal Declaration Content Format Access Pattern Best For
Text file TextFile or Text Human-readable characters, organized in lines Sequential Configuration files, logs, CSV data, reports
Typed file file of T Binary representation of type T Sequential or random Database-like records, structured data
Untyped file file Raw bytes Block-based Copying files, custom formats, high-performance I/O

Each abstraction offers different trade-offs between readability, performance, and flexibility. By the end of this chapter, you will know exactly when to use each one.

The File I/O Workflow

Regardless of file type, Pascal file I/O always follows the same four-step pattern:

  1. Declare a file variable
  2. Associate the variable with a filename on disk (AssignFile)
  3. Open the file (Reset, Rewrite, or Append)
  4. Read or write data
  5. Close the file (CloseFile)

Think of it like a library book. You look up the title in the catalog (AssignFile), check it out (Reset/Rewrite), read or annotate it (Read/Write), and return it (CloseFile). Skipping any step causes problems — just as leaving a library book on your desk indefinitely causes problems for everyone else. This five-step workflow is universal: it applies to files in every programming language, to database connections, to network sockets, and to every other external resource your programs will ever use.


13.2 Text Files

Text files are the simplest and most universal file type. They store data as sequences of characters organized into lines, separated by end-of-line markers (CR+LF on Windows, LF on Unix/macOS). You can open a text file in any text editor and read its contents directly.

Declaring and Associating

var
  F: TextFile;  { or "Text" — both are equivalent }
begin
  AssignFile(F, 'output.txt');  { Associate variable F with the file 'output.txt' }

The AssignFile procedure links a Pascal file variable to a filename on disk. It does not open the file or check whether it exists. Think of it as writing an address on an envelope — you have not mailed anything yet.

💡 Historical note: Older Pascal code uses Assign instead of AssignFile. Free Pascal supports both, but AssignFile is preferred because Assign conflicts with some OOP contexts. Similarly, CloseFile is preferred over Close.

Opening for Writing: Rewrite

AssignFile(F, 'output.txt');
Rewrite(F);               { Create new file (or overwrite existing!) }
WriteLn(F, 'Hello, file world!');
WriteLn(F, 'Line number two.');
CloseFile(F);

Rewrite creates a new file. If a file with that name already exists, it is silently destroyed and replaced with an empty file. This is one of the most dangerous operations in file I/O — there is no confirmation dialog, no undo. Always be certain you want to overwrite before calling Rewrite.

Opening for Reading: Reset

AssignFile(F, 'output.txt');
Reset(F);                  { Open existing file for reading }
while not Eof(F) do
begin
  ReadLn(F, Line);
  WriteLn('Read: ', Line);
end;
CloseFile(F);

Reset opens an existing file for reading. If the file does not exist, you get a runtime error (error 2: file not found). We will learn how to handle this gracefully in Section 13.7.

Opening for Appending: Append

AssignFile(F, 'log.txt');
Append(F);                 { Open existing file, position at end }
WriteLn(F, 'New log entry: ', DateTimeToStr(Now));
CloseFile(F);

Append opens an existing file and positions the write cursor at the end, so new data is added after existing content. This is the correct way to add entries to a log file or accumulate data over time. If the file does not exist, the behavior depends on the compiler — Free Pascal raises an error, so you should check for existence first or handle the error with IOResult.

Writing to Text Files

You use the familiar Write and WriteLn procedures, but with a file variable as the first parameter:

WriteLn(F, 'Name: ', StudentName);
WriteLn(F, 'Grade: ', Grade:6:2);
Write(F, 'No newline after this');

All the formatting specifiers you learned for screen output work identically for file output — field widths, decimal places, everything. The only difference is that the output goes to disk instead of the console.

Reading from Text Files

Reading mirrors writing:

ReadLn(F, Line);           { Read an entire line into a string }
Read(F, IntValue);         { Read an integer }
Read(F, RealValue);        { Read a real number }
ReadLn(F, Ch1, Ch2, Ch3); { Read three characters, then skip to next line }

When reading numeric types, Pascal skips leading whitespace and reads until it encounters a character that is not part of the number. When reading strings with ReadLn, it reads everything up to and including the end-of-line marker.

Closing Files

CloseFile(F);

Always close your files. An unclosed file may lose buffered data that has not yet been written to disk. In a well-structured program, every AssignFile has a matching CloseFile, and every Reset/Rewrite/Append is followed by CloseFile in all execution paths — including error paths.

⚠️ Common mistake: Forgetting to close a file when an exception or early exit occurs. We will see how try...finally blocks help with this pattern.


13.3 Processing Text Files

Reading a file line by line is one of the most common operations in programming. Let us explore the patterns.

The Standard Line-by-Line Loop

var
  F: TextFile;
  Line: string;
  LineCount: Integer;
begin
  AssignFile(F, 'data.txt');
  Reset(F);
  LineCount := 0;
  while not Eof(F) do
  begin
    ReadLn(F, Line);
    Inc(LineCount);
    WriteLn(LineCount:4, ': ', Line);
  end;
  CloseFile(F);
  WriteLn('Total lines: ', LineCount);
end.

The Eof(F) function returns True when the file position is at the end of the file. The while not Eof loop is the idiomatic pattern for processing every line.

A Complete Word Counter

Let us build a more complete text file analysis program. This utility counts lines, words, and characters — similar to the Unix wc command:

program WordCounter;
uses
  SysUtils;
var
  F: TextFile;
  Line: string;
  FileName: string;
  LineCount, WordCount, CharCount: Integer;
  i: Integer;
  InWord: Boolean;
begin
  Write('Enter filename: ');
  ReadLn(FileName);

  if not FileExists(FileName) then
  begin
    WriteLn('Error: File "', FileName, '" not found.');
    Halt(1);
  end;

  AssignFile(F, FileName);
  Reset(F);

  LineCount := 0;
  WordCount := 0;
  CharCount := 0;

  while not Eof(F) do
  begin
    ReadLn(F, Line);
    Inc(LineCount);
    CharCount := CharCount + Length(Line) + 1;  { +1 for newline }

    { Count words by detecting transitions from non-word to word characters }
    InWord := False;
    for i := 1 to Length(Line) do
    begin
      if (Line[i] = ' ') or (Line[i] = #9) then
      begin
        if InWord then
          InWord := False;  { Leaving a word }
      end
      else
      begin
        if not InWord then
        begin
          InWord := True;   { Entering a word }
          Inc(WordCount);
        end;
      end;
    end;
  end;

  CloseFile(F);

  WriteLn;
  WriteLn('File: ', FileName);
  WriteLn('Lines:      ', LineCount:8);
  WriteLn('Words:      ', WordCount:8);
  WriteLn('Characters: ', CharCount:8);
end.

The word-counting logic uses a state variable InWord that tracks whether we are currently inside a word. Each time we transition from whitespace to non-whitespace, we count a new word. This handles multiple consecutive spaces correctly — they do not create phantom "empty" words.

Line-by-Line Copying with Transformation

A common pattern is reading one file, transforming each line, and writing the result to another file. Here is a utility that converts a file to uppercase:

procedure CopyFileUppercase(const SrcName, DstName: string);
var
  SrcFile, DstFile: TextFile;
  Line: string;
begin
  AssignFile(SrcFile, SrcName);
  Reset(SrcFile);

  AssignFile(DstFile, DstName);
  Rewrite(DstFile);

  while not Eof(SrcFile) do
  begin
    ReadLn(SrcFile, Line);
    WriteLn(DstFile, UpCase(Line));  { Transform before writing }
  end;

  CloseFile(DstFile);
  CloseFile(SrcFile);
  WriteLn('Copied and converted: ', SrcName, ' -> ', DstName);
end;

This pattern — read, transform, write — is the backbone of countless utility programs. You can adapt it for line numbering (prepend line numbers), filtering (only write lines that match a condition), or reformatting (change delimiters, trim whitespace).

Reading Word by Word

For text analysis, you sometimes need individual words rather than whole lines. One approach uses Read on a string variable from a text file (without Ln), which in Free Pascal reads characters until it encounters whitespace or end-of-line. However, this behavior can be subtle. The safer and more portable approach is to read lines with ReadLn and then split them manually, as we demonstrated in the word counter above.

The Eof and Eoln Functions

Pascal provides two end-detection functions for text files:

  • Eof(F) — returns True when the file position is at the end of the file
  • Eoln(F) — returns True when the file position is at the end of the current line

These are essential for robust file reading. A common pattern for reading character by character:

while not Eof(F) do
begin
  while not Eoln(F) do
  begin
    Read(F, Ch);
    { Process character }
  end;
  ReadLn(F);  { Consume end-of-line marker, advance to next line }
end;

Reading and Parsing CSV Files

Reading a CSV file requires splitting each line by commas. Here is a complete CSV reader that loads student data:

procedure ReadCSVFile(const FileName: string);
var
  F: TextFile;
  Line: string;
  CommaPos: Integer;
  Name, GradeStr, PctStr: string;
  LineNum: Integer;
begin
  AssignFile(F, FileName);
  Reset(F);

  { Skip the header line }
  ReadLn(F, Line);
  LineNum := 1;

  while not Eof(F) do
  begin
    ReadLn(F, Line);
    Inc(LineNum);

    { Parse: Name,Grade,Percentage }
    CommaPos := Pos(',', Line);
    if CommaPos = 0 then
    begin
      WriteLn('Warning: Skipping malformed line ', LineNum);
      Continue;
    end;
    Name := Copy(Line, 1, CommaPos - 1);
    Delete(Line, 1, CommaPos);

    CommaPos := Pos(',', Line);
    if CommaPos = 0 then
    begin
      WriteLn('Warning: Skipping malformed line ', LineNum);
      Continue;
    end;
    GradeStr := Copy(Line, 1, CommaPos - 1);
    PctStr := Copy(Line, CommaPos + 1, Length(Line));

    WriteLn('  Student: ', Name, ', Grade: ', GradeStr, ', Pct: ', PctStr);
  end;

  CloseFile(F);
end;

This parser handles the most common CSV format but does not handle quoted fields (fields containing commas enclosed in double quotes). For production CSV parsing, you would use a library or write a more complete parser that tracks whether you are inside a quoted field.

Writing Structured Text: CSV Files

Text files are ideal for CSV (Comma-Separated Values) data — a format that every spreadsheet application can import:

procedure WriteGradesToCSV(const FileName: string;
                           const Students: array of TStudent;
                           Count: Integer);
var
  F: TextFile;
  I: Integer;
begin
  AssignFile(F, FileName);
  Rewrite(F);
  WriteLn(F, 'Name,Grade,Percentage');  { Header row }
  for I := 0 to Count - 1 do
    WriteLn(F, Students[I].Name, ',',
               Students[I].Grade, ',',
               Students[I].Percentage:0:1);
  CloseFile(F);
end;

This produces a file like:

Name,Grade,Percentage
Alice Johnson,A,94.5
Bob Smith,B+,87.2
Carol Davis,A-,91.8

📊 Spaced Review from Chapter 6: Write a REPEAT..UNTIL loop that reads lines from a file until Eof. Answer: repeat ReadLn(F, Line); { process } until Eof(F); — but beware: if the file is empty, this reads past the end. The while not Eof pattern is usually safer.


13.4 Typed Files: file of T

Text files are wonderful for human-readable data, but they have limitations. Every value must be converted to and from text, which is slow and can introduce rounding errors for real numbers. Field boundaries are ambiguous — what if a student's name contains a comma in your CSV?

Typed files solve these problems by storing data in its native binary representation. A file of Integer stores 4-byte integers. A file of TStudent stores the exact bytes of a TStudent record. No conversion, no ambiguity, and very fast.

Declaring Typed Files

type
  TStudent = record
    Name: string[50];    { Important: fixed-length string! }
    Grade: Char;
    Percentage: Real;
  end;

var
  StudentFile: file of TStudent;

⚠️ Critical rule: Records stored in typed files must have a fixed size. This means you must use short strings (string[N]) rather than dynamic strings (string or AnsiString). A dynamic string is just a pointer — writing a pointer to a file is meaningless. The compiler will warn you, but heed this rule carefully.

Writing Records

var
  StudentFile: file of TStudent;
  Student: TStudent;
begin
  AssignFile(StudentFile, 'students.dat');
  Rewrite(StudentFile);

  Student.Name := 'Alice Johnson';
  Student.Grade := 'A';
  Student.Percentage := 94.5;
  Write(StudentFile, Student);

  Student.Name := 'Bob Smith';
  Student.Grade := 'B';
  Student.Percentage := 87.2;
  Write(StudentFile, Student);

  CloseFile(StudentFile);
end.

Each Write call stores the entire record — all its bytes, in order — at the current file position. The file grows by SizeOf(TStudent) bytes with each write.

Reading Records

AssignFile(StudentFile, 'students.dat');
Reset(StudentFile);
while not Eof(StudentFile) do
begin
  Read(StudentFile, Student);
  WriteLn('Name: ', Student.Name,
          ', Grade: ', Student.Grade,
          ', Pct: ', Student.Percentage:0:1);
end;
CloseFile(StudentFile);

Each Read call loads exactly SizeOf(TStudent) bytes from the file into the record variable. The types must match exactly — reading a file of TStudent with a differently-shaped record produces garbage.

Step-by-Step Trace: Writing and Reading Typed Files

Let us trace through a typed file operation in complete detail to make sure you understand what happens at each step.

Suppose TStudent has SizeOf(TStudent) = 60 bytes (50 bytes for Name, 1 byte for Grade, 1 byte padding, 8 bytes for Percentage).

Writing two records:

Step 1: Rewrite(StudentFile)
  File position: 0
  File size: 0 bytes (empty file created)

Step 2: Write(StudentFile, Student1)  { Alice, 'A', 94.5 }
  Writes 60 bytes starting at position 0
  File position advances to record 1
  File size: 60 bytes

Step 3: Write(StudentFile, Student2)  { Bob, 'B', 87.2 }
  Writes 60 bytes starting at byte offset 60
  File position advances to record 2
  File size: 120 bytes

Step 4: CloseFile(StudentFile)
  Flushes any buffered data to disk
  File is closed

Reading those records back:

Step 1: Reset(StudentFile)
  File position: 0 (start of file)
  FileSize(StudentFile) = 2 (two records of 60 bytes each)

Step 2: Eof(StudentFile) = False  (position 0 < 2 records)
  Read(StudentFile, Student)
  Reads 60 bytes from position 0 → Student = {Alice, A, 94.5}
  File position advances to record 1

Step 3: Eof(StudentFile) = False  (position 1 < 2 records)
  Read(StudentFile, Student)
  Reads 60 bytes from position 60 → Student = {Bob, B, 87.2}
  File position advances to record 2

Step 4: Eof(StudentFile) = True  (position 2 = 2 records)
  Loop exits

Step 5: CloseFile(StudentFile)

The key insight is that the file position is measured in records, not bytes. Seek(StudentFile, 1) moves to byte offset 1 * SizeOf(TStudent) = 60, not to byte 1. This record-level abstraction is what makes typed files so convenient — you think in records, and Pascal handles the byte arithmetic.

Binary Files Are Not Human-Readable

If you open students.dat in a text editor, you will see a mess of binary characters. This is expected and correct. The data is stored efficiently for the computer, not for human eyes. If you need to inspect the data, write a small utility program that reads the typed file and prints its contents.

GradeBook Pro: Saving and Loading

Let us extend our running GradeBook example with file persistence:

procedure SaveGradeBook(const FileName: string;
                        const Students: array of TStudentRec;
                        Count: Integer);
var
  F: file of TStudentRec;
  I: Integer;
begin
  AssignFile(F, FileName);
  Rewrite(F);
  for I := 0 to Count - 1 do
    Write(F, Students[I]);
  CloseFile(F);
  WriteLn('Saved ', Count, ' students to ', FileName);
end;

procedure LoadGradeBook(const FileName: string;
                        var Students: array of TStudentRec;
                        var Count: Integer);
var
  F: file of TStudentRec;
begin
  AssignFile(F, FileName);
  Reset(F);
  Count := 0;
  while not Eof(F) do
  begin
    Read(F, Students[Count]);
    Inc(Count);
  end;
  CloseFile(F);
  WriteLn('Loaded ', Count, ' students from ', FileName);
end;

💡 Theme 5 — Algorithms + Data Structures = Programs: A typed file is, in effect, an array on disk. The records are laid out contiguously, just as array elements are laid out in memory. This structural similarity is what makes random access possible, as we will see next.


13.5 Random Access

Text files are strictly sequential — you read from the beginning and work your way through. But typed files support random access, meaning you can jump directly to any record by its index. This is the feature that transforms a typed file from a simple data dump into something approaching a database.

The Big Three: Seek, FilePos, FileSize

Pascal provides three functions for random access on typed files:

Procedure/Function Purpose Example
Seek(F, N) Move the file pointer to record number N (0-based) Seek(F, 5) moves to the 6th record
FilePos(F) Return the current record position (0-based) Pos := FilePos(F)
FileSize(F) Return the total number of records in the file Count := FileSize(F)

Jumping to a Specific Record

AssignFile(StudentFile, 'students.dat');
Reset(StudentFile);

{ Jump to the 3rd record (index 2) }
Seek(StudentFile, 2);
Read(StudentFile, Student);
WriteLn('Third student: ', Student.Name);

CloseFile(StudentFile);

This is dramatically more efficient than reading all records sequentially when you only need one. For a file with 10,000 records, sequential search reads an average of 5,000 records; Seek reads exactly one.

Updating a Record in Place

One of the most powerful features of typed files is the ability to modify a single record without rewriting the entire file:

procedure UpdateStudentGrade(const FileName: string;
                             RecordIndex: Integer;
                             NewGrade: Char;
                             NewPercentage: Real);
var
  F: file of TStudentRec;
  Student: TStudentRec;
begin
  AssignFile(F, FileName);
  Reset(F);

  { Read the existing record }
  Seek(F, RecordIndex);
  Read(F, Student);

  { Modify it }
  Student.Grade := NewGrade;
  Student.Percentage := NewPercentage;

  { Write it back at the same position }
  Seek(F, RecordIndex);  { Must seek back — Read advanced the pointer! }
  Write(F, Student);

  CloseFile(F);
end;

⚠️ Watch out: After Read(F, Student), the file pointer has advanced past the record you just read. You must Seek back to the same position before writing, or you will overwrite the next record.

Detailed Random Access Walkthrough

Let us trace a complete sequence of random access operations on a file with five student records to make the seek positions crystal clear:

File contents (5 records, indices 0-4):
  [0] Alice    A  94.5
  [1] Bob      B  87.2
  [2] Carol    A- 91.8
  [3] Dave     C+ 78.3
  [4] Eve      B+ 88.0

Operation 1: Read the third student (index 2).

Seek(StudentFile, 2);        { Position = 2 }
Read(StudentFile, Student);  { Reads record at index 2 → Carol }
                             { Position advances to 3 }

After the read, Student.Name = 'Carol', Student.Grade = 'A-', Student.Percentage = 91.8. The file position is now 3.

Operation 2: Update Dave's grade without moving sequentially.

{ Position is already 3 from the previous read }
Read(StudentFile, Student);  { Reads record at index 3 → Dave }
                             { Position advances to 4 }
Student.Grade := 'B';
Student.Percentage := 82.0;
Seek(StudentFile, 3);        { Must seek BACK to index 3 }
Write(StudentFile, Student); { Overwrites Dave's record in place }
                             { Position advances to 4 }

Operation 3: Count total records.

WriteLn('Total students: ', FileSize(StudentFile));  { 5 }
WriteLn('Current position: ', FilePos(StudentFile)); { 4 }

Operation 4: Read the last record.

Seek(StudentFile, FileSize(StudentFile) - 1);  { Seek to index 4 }
Read(StudentFile, Student);                     { Eve }

The expression FileSize(F) - 1 is the index of the last record, because records are 0-indexed. This pattern works regardless of how many records are in the file.

Appending to a Typed File

To add a new record at the end of an existing typed file:

AssignFile(F, 'students.dat');
Reset(F);                       { Open existing file }
Seek(F, FileSize(F));           { Move to one past the last record }
Write(F, NewStudent);           { Write new record at the end }
CloseFile(F);

The expression FileSize(F) gives the number of records, and since records are 0-indexed, this is the position just past the last record — exactly where we want to append.

Deleting a Record

Typed files do not have a built-in delete operation. The two common strategies are:

  1. Mark-as-deleted: Add a Deleted: Boolean field to the record. Set it to True instead of physically removing the record. When displaying data, skip deleted records. Periodically "compact" the file by copying non-deleted records to a new file.

  2. Shift and truncate: Copy the last record over the one to be deleted, then truncate the file. This is efficient but changes record order.

{ Strategy 2: Move last record into deleted slot }
procedure DeleteRecord(const FileName: string; Index: Integer);
var
  F: file of TStudentRec;
  LastRec: TStudentRec;
  Size: Integer;
begin
  AssignFile(F, FileName);
  Reset(F);
  Size := FileSize(F);

  if (Index < 0) or (Index >= Size) then
  begin
    CloseFile(F);
    WriteLn('Error: index out of range');
    Exit;
  end;

  if Index < Size - 1 then
  begin
    { Read the last record }
    Seek(F, Size - 1);
    Read(F, LastRec);
    { Write it over the one being deleted }
    Seek(F, Index);
    Write(F, LastRec);
  end;

  { Truncate: remove the last record }
  Seek(F, Size - 1);
  Truncate(F);

  CloseFile(F);
end;

The Truncate(F) procedure removes all records from the current file position to the end of the file.

📊 Spaced Review from Chapter 9: How do you pass an array to a procedure without copying it? Use the var or const keyword: procedure Process(const Arr: TMyArray). Without these, Pascal copies the entire array onto the stack, which is slow for large arrays and wasteful for read-only access.


13.6 Untyped Files

Sometimes you need to work with raw bytes — copying a file byte-for-byte, implementing a custom binary format, or performing high-performance I/O where the overhead of per-record reads is too high. Pascal's untyped files provide this capability.

Declaring and Opening Untyped Files

var
  F: file;  { No "of T" — this is an untyped file }
begin
  AssignFile(F, 'image.bin');
  Reset(F, 1);     { Open for reading; record size = 1 byte }
  { ... }
  CloseFile(F);
end;

The second parameter to Reset and Rewrite specifies the record size in bytes. A record size of 1 gives you byte-level access. A record size of 512 or 4096 aligns with disk sectors for better performance.

BlockRead and BlockWrite

Untyped files use BlockRead and BlockWrite instead of Read and Write:

var
  F: file;
  Buffer: array[1..4096] of Byte;
  BytesRead: Integer;
begin
  AssignFile(F, 'data.bin');
  Reset(F, 1);

  BlockRead(F, Buffer, SizeOf(Buffer), BytesRead);
  WriteLn('Read ', BytesRead, ' bytes');

  CloseFile(F);
end;

The fourth parameter (BytesRead) receives the actual number of records read, which may be less than requested if you are near the end of the file. Always check this value.

File Copy Utility

A classic use of untyped files is copying a file of any type:

procedure CopyFile(const Source, Dest: string);
const
  BufSize = 65536;  { 64 KB buffer }
var
  SrcFile, DstFile: file;
  Buffer: array[1..BufSize] of Byte;
  BytesRead: Integer;
begin
  AssignFile(SrcFile, Source);
  Reset(SrcFile, 1);

  AssignFile(DstFile, Dest);
  Rewrite(DstFile, 1);

  repeat
    BlockRead(SrcFile, Buffer, BufSize, BytesRead);
    if BytesRead > 0 then
      BlockWrite(DstFile, Buffer, BytesRead);
  until BytesRead = 0;

  CloseFile(DstFile);
  CloseFile(SrcFile);
end;

This procedure works for any file — text, binary, image, executable — because it operates on raw bytes without interpreting the content.

Performance Comparison: Untyped vs. Typed vs. Text

To appreciate when untyped files matter, consider copying a 10 MB file:

  • Text file (line by line): Reads each line, interprets newline characters, writes each line. Slowest, because each ReadLn and WriteLn involves character-by-character processing.
  • Typed file (record by record): Reads one record at a time (say, 100 bytes). Faster than text, but each Read call has system call overhead.
  • Untyped file (64 KB blocks): Reads 65,536 bytes at a time. Fewest system calls, highest throughput. For a 10 MB file, that is about 160 block reads versus 100,000 record reads.

The difference is dramatic for large files. A 100 MB file copy using 64 KB blocks completes in a fraction of a second. The same copy using 100-byte typed file records might take several seconds. For small files (under 1 MB), the difference is negligible.

Determining File Size with Untyped Files

With untyped files, FileSize returns the number of records, where the record size was set in the Reset or Rewrite call. To get the file size in bytes, use a record size of 1:

function GetFileSizeInBytes(const FileName: string): Int64;
var
  F: file;
begin
  AssignFile(F, FileName);
  Reset(F, 1);
  Result := FileSize(F);
  CloseFile(F);
end;

Alternatively, use SysUtils.FileSize (the function, not the file procedure) which returns the file size directly:

uses SysUtils;
var
  Size: Int64;
begin
  Size := FileSize('data.bin');
  WriteLn('File size: ', Size, ' bytes');
  WriteLn('That is ', (Size / 1024):0:1, ' KB');
end;

When to Use Untyped Files

Untyped files are the right choice when:

  • You are copying or transforming files without interpreting their content
  • You are implementing a custom binary format with mixed data types
  • You need maximum I/O throughput and want to control buffer sizes
  • You are interfacing with external data formats not representable as Pascal records

For most application programming, text files and typed files are more appropriate.


13.7 Error Handling with IOResult

File operations are inherently unreliable. The file might not exist. The disk might be full. The user might not have permission. A network drive might be disconnected. Robust programs must anticipate and handle these failures.

The {$I-} and {$I+} Compiler Directives

By default, Pascal's I/O checking is enabled ({$I+}). When an I/O operation fails, the program halts with a runtime error. This is helpful for debugging but unacceptable in production code.

The {$I-} directive disables automatic I/O checking. After each I/O operation, you must call IOResult to check whether it succeeded:

var
  F: TextFile;
  IOCode: Integer;
begin
  AssignFile(F, 'might_not_exist.txt');

  {$I-}
  Reset(F);
  IOCode := IOResult;
  {$I+}

  if IOCode <> 0 then
  begin
    WriteLn('Could not open file. Error code: ', IOCode);
    Exit;
  end;

  { File is open, proceed normally }
  while not Eof(F) do
  begin
    ReadLn(F, Line);
    WriteLn(Line);
  end;
  CloseFile(F);
end.

Common IOResult Codes

Code Meaning
0 Success
2 File not found
3 Path not found
4 Too many open files
5 Access denied
100 Disk read error
101 Disk write error
103 File not open

The FileExists Function

For the common case of checking whether a file exists before opening it, Free Pascal's SysUtils unit provides FileExists:

uses SysUtils;

if FileExists('data.txt') then
begin
  AssignFile(F, 'data.txt');
  Reset(F);
  { ... }
  CloseFile(F);
end
else
  WriteLn('File not found: data.txt');

This is cleaner than the {$I-} / IOResult pattern for simple existence checks, but IOResult is still necessary for handling errors during reading or writing.

A Robust File-Opening Function

Here is a reusable function that combines both approaches:

function SafeOpenForReading(var F: TextFile;
                            const FileName: string): Boolean;
var
  IOCode: Integer;
begin
  Result := False;
  if not FileExists(FileName) then
  begin
    WriteLn('Error: File "', FileName, '" does not exist.');
    Exit;
  end;

  AssignFile(F, FileName);
  {$I-}
  Reset(F);
  IOCode := IOResult;
  {$I+}

  if IOCode <> 0 then
  begin
    WriteLn('Error: Cannot open "', FileName, '". IOResult = ', IOCode);
    Exit;
  end;

  Result := True;
end;

⚠️ Critical: You must call IOResult immediately after the I/O operation. Calling any other I/O operation — even WriteLn to the screen — resets the result. The sequence {$I-} Reset(F); WriteLn('Opened!'); IOCode := IOResult; {$I+} is a bug because WriteLn clears the error.

Using Try...Finally for Clean-Up

Free Pascal supports try...finally blocks, which guarantee that clean-up code runs even if an exception occurs:

AssignFile(F, 'data.txt');
Reset(F);
try
  while not Eof(F) do
  begin
    ReadLn(F, Line);
    ProcessLine(Line);  { Might raise an exception }
  end;
finally
  CloseFile(F);  { Always executed, even if ProcessLine crashes }
end;

This pattern ensures files are always closed, which prevents resource leaks and data loss.


13.8 File Utilities

Free Pascal provides several utility procedures and functions for managing files at the filesystem level.

Rename and Erase

{ Rename a file }
AssignFile(F, 'old_name.txt');
Rename(F, 'new_name.txt');

{ Delete a file }
AssignFile(F, 'temp.txt');
Erase(F);

Both operate on closed files — do not rename or erase a file that is currently open.

Error Handling Patterns with IOResult

The {$I-} and IOResult mechanism can be used to build robust file operations. Here are the most common patterns.

Pattern 1: Safe file creation with directory check.

function SafeCreateFile(var F: TextFile; const FileName: string): Boolean;
var
  Dir: string;
  IOCode: Integer;
begin
  Result := False;
  Dir := ExtractFilePath(FileName);

  { Ensure the directory exists }
  if (Dir <> '') and not DirectoryExists(Dir) then
  begin
    WriteLn('Error: Directory "', Dir, '" does not exist.');
    Exit;
  end;

  AssignFile(F, FileName);
  {$I-}
  Rewrite(F);
  IOCode := IOResult;
  {$I+}

  if IOCode <> 0 then
  begin
    case IOCode of
      3: WriteLn('Error: Path not found.');
      5: WriteLn('Error: Access denied. Check file permissions.');
    else
      WriteLn('Error: Could not create file. IOResult = ', IOCode);
    end;
    Exit;
  end;

  Result := True;
end;

Pattern 2: Retry loop for locked files.

In multi-user environments, a file might be temporarily locked by another process. A retry loop gives the other process time to release the lock:

function OpenWithRetry(var F: TextFile; const FileName: string;
                       MaxAttempts: Integer): Boolean;
var
  Attempt, IOCode: Integer;
begin
  Result := False;
  AssignFile(F, FileName);

  for Attempt := 1 to MaxAttempts do
  begin
    {$I-}
    Reset(F);
    IOCode := IOResult;
    {$I+}

    if IOCode = 0 then
    begin
      Result := True;
      Exit;
    end;

    if Attempt < MaxAttempts then
    begin
      WriteLn('File busy, retrying (', Attempt, '/', MaxAttempts, ')...');
      Sleep(500);  { Wait 500ms before retrying — requires SysUtils }
    end;
  end;

  WriteLn('Error: Could not open "', FileName, '" after ', MaxAttempts, ' attempts.');
end;

Pattern 3: Atomic write using temporary file.

To prevent data loss if the program crashes during writing, write to a temporary file first, then rename:

procedure SafeWriteData(const FileName: string; const Data: string);
var
  F: TextFile;
  TempName: string;
begin
  TempName := FileName + '.tmp';
  AssignFile(F, TempName);
  Rewrite(F);
  WriteLn(F, Data);
  CloseFile(F);

  { Only after successful write do we replace the original }
  if FileExists(FileName) then
    DeleteFile(FileName);
  RenameFile(TempName, FileName);
end;

If the program crashes during the WriteLn, the original file is untouched. The rename operation is effectively atomic on most filesystems — it either succeeds completely or fails completely.

SysUtils File Functions

The SysUtils unit provides higher-level alternatives that work with filenames directly (no file variable needed):

uses SysUtils;

{ Check existence }
if FileExists('config.ini') then ...
if DirectoryExists('data') then ...

{ Rename }
RenameFile('old.txt', 'new.txt');

{ Delete }
DeleteFile('temp.txt');

{ Create a directory }
CreateDir('output');
ForceDirectories('output/reports/2026');  { Creates all intermediate dirs }

{ Extract parts of a path }
WriteLn(ExtractFileName('/home/user/data.txt'));    { 'data.txt' }
WriteLn(ExtractFileExt('/home/user/data.txt'));     { '.txt' }
WriteLn(ExtractFilePath('/home/user/data.txt'));    { '/home/user/' }
WriteLn(ChangeFileExt('data.txt', '.bak'));         { 'data.bak' }

File Age and Timestamps

uses SysUtils;

var
  Age: LongInt;
begin
  Age := FileAge('data.txt');
  if Age <> -1 then
    WriteLn('Last modified: ', DateTimeToStr(FileDateToDateTime(Age)))
  else
    WriteLn('File not found or inaccessible.');
end;

File timestamps are useful for implementing features like "only process files newer than the last run" or displaying "last saved" information to the user.

Getting File Information

uses SysUtils;

var
  Info: TSearchRec;
begin
  if FindFirst('*.txt', faAnyFile, Info) = 0 then
  begin
    repeat
      WriteLn(Info.Name, ' — ', Info.Size, ' bytes');
    until FindNext(Info) <> 0;
    FindClose(Info);
  end;
end.

This pattern enumerates all files matching a wildcard pattern — useful for batch processing.


13.9 Choosing the Right File Type

Now that we have covered all three file types, let us formalize the decision process.

Decision Framework

Use a text file when: - The data needs to be human-readable or editable with a text editor - You are exchanging data with other programs via CSV, TSV, or similar formats - The data is fundamentally character-oriented (logs, configuration, reports) - File size is not a major concern - You do not need random access

Use a typed file when: - The data consists of fixed-size records (e.g., database entries) - You need random access to individual records by index - Performance matters — binary I/O avoids conversion overhead - The file will only be read by Pascal programs (or programs that understand the binary format) - Data integrity is important — no parsing ambiguities

Use an untyped file when: - You are copying or transforming files without interpreting their content - You need maximum control over buffer sizes and I/O patterns - You are implementing a custom binary format with variable-length records or mixed types - You are working with files produced by non-Pascal software in a binary format

Comparison Table

Feature Text File Typed File Untyped File
Human-readable Yes No No
Random access No Yes Manual
Type safety Low High None
Speed Slowest Fast Fastest
Portability Excellent Limited Variable
Record size Variable Fixed Any
String handling Natural Fixed-length only Manual

A Practical Rule of Thumb

If you are saving configuration or exchanging data with other tools, use text files. If you are building a database-like feature in your program, use typed files. If you are doing something unusual, use untyped files. When in doubt, start with text files — you can always optimize later.

💡 Theme 3 — Pascal as a Living Language: Modern Pascal through Free Pascal provides all three file types, giving you the full spectrum from convenience to control. Many "modern" languages offer only text/stream I/O and require external libraries for structured binary files. Pascal's typed file system is elegantly built into the language.


13.10 A Complete File-Based Database: The Contact Manager

Let us put everything together in a complete example that demonstrates text files, typed files, error handling, and random access. This contact manager is a mini-database that stores, searches, updates, and deletes contact records.

The Record Structure

type
  TContact = record
    Name: string[40];
    Phone: string[20];
    Email: string[50];
    Active: Boolean;       { False = deleted (soft delete) }
  end;

The Active field implements the "mark-as-deleted" strategy from Section 13.5. Instead of physically removing records, we set Active := False. This avoids the expense of shifting records.

Core Operations

const
  DB_FILE = 'contacts.dat';

function CountActiveContacts: Integer;
var
  F: file of TContact;
  Contact: TContact;
begin
  Result := 0;
  if not FileExists(DB_FILE) then Exit;

  AssignFile(F, DB_FILE);
  Reset(F);
  while not Eof(F) do
  begin
    Read(F, Contact);
    if Contact.Active then
      Inc(Result);
  end;
  CloseFile(F);
end;

procedure AddContact(const AName, APhone, AEmail: string);
var
  F: file of TContact;
  Contact: TContact;
begin
  Contact.Name := AName;
  Contact.Phone := APhone;
  Contact.Email := AEmail;
  Contact.Active := True;

  AssignFile(F, DB_FILE);
  if FileExists(DB_FILE) then
  begin
    Reset(F);
    Seek(F, FileSize(F));   { Append at end }
  end
  else
    Rewrite(F);              { Create new file }

  Write(F, Contact);
  CloseFile(F);
  WriteLn('Contact "', AName, '" added successfully.');
end;

function FindContactByName(const SearchName: string;
                           var FoundIndex: Integer): Boolean;
var
  F: file of TContact;
  Contact: TContact;
begin
  Result := False;
  FoundIndex := -1;
  if not FileExists(DB_FILE) then Exit;

  AssignFile(F, DB_FILE);
  Reset(F);
  while not Eof(F) do
  begin
    Read(F, Contact);
    if Contact.Active and (Pos(SearchName, Contact.Name) > 0) then
    begin
      FoundIndex := FilePos(F) - 1;  { FilePos is now PAST the record }
      Result := True;
      CloseFile(F);
      Exit;
    end;
  end;
  CloseFile(F);
end;

procedure DeleteContact(Index: Integer);
var
  F: file of TContact;
  Contact: TContact;
begin
  AssignFile(F, DB_FILE);
  Reset(F);

  if (Index < 0) or (Index >= FileSize(F)) then
  begin
    WriteLn('Error: Invalid contact index.');
    CloseFile(F);
    Exit;
  end;

  Seek(F, Index);
  Read(F, Contact);
  Contact.Active := False;   { Soft delete }
  Seek(F, Index);             { Seek back }
  Write(F, Contact);          { Overwrite with Active = False }

  CloseFile(F);
  WriteLn('Contact "', Contact.Name, '" deleted.');
end;

This contact manager demonstrates typed file operations working together: AddContact uses append, FindContactByName uses sequential search, and DeleteContact uses random access to update a record in place. The soft-delete pattern means that deleting a contact is O(1) — we seek directly to it and flip a Boolean — rather than O(n) if we had to shift all subsequent records.

Exporting to Text

A common need is exporting binary data to a human-readable format. Here is a procedure that exports active contacts to a CSV text file:

procedure ExportToCSV(const CSVFileName: string);
var
  BinFile: file of TContact;
  TextFile_: TextFile;
  Contact: TContact;
  Count: Integer;
begin
  if not FileExists(DB_FILE) then
  begin
    WriteLn('No contacts to export.');
    Exit;
  end;

  AssignFile(BinFile, DB_FILE);
  Reset(BinFile);

  AssignFile(TextFile_, CSVFileName);
  Rewrite(TextFile_);
  WriteLn(TextFile_, 'Name,Phone,Email');  { CSV header }

  Count := 0;
  while not Eof(BinFile) do
  begin
    Read(BinFile, Contact);
    if Contact.Active then
    begin
      WriteLn(TextFile_, Contact.Name, ',', Contact.Phone, ',', Contact.Email);
      Inc(Count);
    end;
  end;

  CloseFile(TextFile_);
  CloseFile(BinFile);
  WriteLn('Exported ', Count, ' contacts to "', CSVFileName, '".');
end;

This demonstrates the interplay between typed files (for efficient internal storage) and text files (for human-readable export). The binary file is the program's working format; the CSV is the exchange format. This two-format approach is standard practice in professional software.


13.11 Crypts of Pascalia: Save and Load Game State

Our text adventure game has been growing chapter by chapter. But until now, the player loses all progress when they quit. Let us fix that with a save/load system.

The Save Game Record

First, we define a record that captures everything about the player's current state:

type
  TSaveGame = record
    PlayerName: string[30];
    CurrentRoom: Integer;
    HitPoints: Integer;
    MaxHitPoints: Integer;
    Gold: Integer;
    HasSword: Boolean;
    HasKey: Boolean;
    HasPotion: Boolean;
    HasMap: Boolean;
    MonstersDefeated: Integer;
    TurnsPlayed: Integer;
    SaveDate: string[20];
  end;

All fields are fixed-size. The string[30] and string[20] declarations ensure the record has a predictable size, which is essential for typed file storage.

Saving the Game

procedure SaveGame(const Player: TPlayerState);
var
  F: file of TSaveGame;
  Save: TSaveGame;
  IOCode: Integer;
begin
  { Copy player state into save record }
  Save.PlayerName := Player.Name;
  Save.CurrentRoom := Player.RoomID;
  Save.HitPoints := Player.HP;
  Save.MaxHitPoints := Player.MaxHP;
  Save.Gold := Player.Gold;
  Save.HasSword := Player.Inventory[INV_SWORD];
  Save.HasKey := Player.Inventory[INV_KEY];
  Save.HasPotion := Player.Inventory[INV_POTION];
  Save.HasMap := Player.Inventory[INV_MAP];
  Save.MonstersDefeated := Player.Kills;
  Save.TurnsPlayed := Player.Turns;
  Save.SaveDate := FormatDateTime('yyyy-mm-dd hh:nn:ss', Now);

  AssignFile(F, 'crypts_save.dat');
  {$I-}
  Rewrite(F);
  IOCode := IOResult;
  {$I+}

  if IOCode <> 0 then
  begin
    WriteLn('*** ERROR: Could not create save file! (Code ', IOCode, ')');
    Exit;
  end;

  Write(F, Save);
  CloseFile(F);
  WriteLn('Game saved successfully. [', Save.SaveDate, ']');
end;

Loading the Game

function LoadGame(var Player: TPlayerState): Boolean;
var
  F: file of TSaveGame;
  Save: TSaveGame;
  IOCode: Integer;
begin
  Result := False;

  if not FileExists('crypts_save.dat') then
  begin
    WriteLn('No save file found.');
    Exit;
  end;

  AssignFile(F, 'crypts_save.dat');
  {$I-}
  Reset(F);
  IOCode := IOResult;
  {$I+}

  if IOCode <> 0 then
  begin
    WriteLn('*** ERROR: Could not open save file! (Code ', IOCode, ')');
    Exit;
  end;

  Read(F, Save);
  CloseFile(F);

  { Restore player state from save record }
  Player.Name := Save.PlayerName;
  Player.RoomID := Save.CurrentRoom;
  Player.HP := Save.HitPoints;
  Player.MaxHP := Save.MaxHitPoints;
  Player.Gold := Save.Gold;
  Player.Inventory[INV_SWORD] := Save.HasSword;
  Player.Inventory[INV_KEY] := Save.HasKey;
  Player.Inventory[INV_POTION] := Save.HasPotion;
  Player.Inventory[INV_MAP] := Save.HasMap;
  Player.Kills := Save.MonstersDefeated;
  Player.Turns := Save.TurnsPlayed;

  WriteLn('Game loaded! Welcome back, ', Player.Name, '.');
  WriteLn('Saved on: ', Save.SaveDate);
  WriteLn('You are in room ', Player.RoomID, ' with ', Player.HP, ' HP.');

  Result := True;
end;

Integrating into the Game Loop

{ In the main game loop's command handler: }
'SAVE': SaveGame(Player);
'LOAD': if LoadGame(Player) then
          DescribeRoom(Rooms[Player.RoomID]);
'QUIT': begin
          Write('Save before quitting? (Y/N): ');
          ReadLn(Choice);
          if UpCase(Choice) = 'Y' then
            SaveGame(Player);
          Running := False;
        end;

Notice how the save/load feature prompts the player to save before quitting. This is a small but important UX detail — players who forget to save will thank you.

Verifying Save File Integrity

A corrupted save file is worse than no save file — it can crash the game or load nonsensical state. A simple integrity check is to verify that the file is exactly the size of one TSaveGame record:

function IsSaveFileValid(const FileName: string): Boolean;
var
  F: file of TSaveGame;
begin
  Result := False;
  if not FileExists(FileName) then Exit;

  AssignFile(F, FileName);
  {$I-}
  Reset(F);
  if IOResult <> 0 then Exit;
  {$I+}

  { A valid save file has exactly one record }
  Result := (FileSize(F) = 1);
  CloseFile(F);
end;

For production games, you would add a magic number (a constant value at the start of the record that identifies the file format) and a version field (so older save files can be detected and converted). Here is what a more robust save record might look like:

type
  TSaveGame = record
    Magic: LongInt;           { Always $50415343 = 'PASC' }
    Version: Integer;         { Save format version number }
    PlayerName: string[30];
    CurrentRoom: Integer;
    HitPoints: Integer;
    { ... other fields ... }
    Checksum: LongInt;        { Simple checksum of all preceding fields }
  end;

When loading, verify that Magic = $50415343 and that the checksum matches. If either check fails, the file is corrupt — display a friendly error rather than crashing.

Multiple Save Slots

For a more advanced implementation, you could use the random-access features of typed files to support multiple save slots:

procedure SaveToSlot(const Player: TPlayerState; Slot: Integer);
var
  F: file of TSaveGame;
  Save: TSaveGame;
begin
  { ... populate Save record ... }

  AssignFile(F, 'crypts_saves.dat');
  if FileExists('crypts_saves.dat') then
    Reset(F)
  else
    Rewrite(F);

  Seek(F, Slot);   { Slot 0, 1, 2, etc. }
  Write(F, Save);
  CloseFile(F);
  WriteLn('Saved to slot ', Slot, '.');
end;

Each slot occupies exactly SizeOf(TSaveGame) bytes, so Seek(F, 3) jumps directly to the 4th save slot. The player could have 10 different save games in a single file, each instantly accessible.


13.12 Project Checkpoint: PennyWise File Storage

It is time to give PennyWise a memory. Until now, our expense tracker has been losing all data when the program ends. In this checkpoint, we add persistent storage using typed files. This is the transformation that turns PennyWise from a demonstration program into a genuinely useful tool.

The checkpoint requires three capabilities: (1) saving all expenses to a binary file when the user quits, (2) loading them back when the program starts, and (3) handling errors gracefully — because real users will delete files, move them, and run the program from unexpected directories.

The Expense Record

If you have been following the progressive project, you already have something like this:

type
  TExpenseRec = record
    Date: string[10];       { 'YYYY-MM-DD' }
    Category: string[20];   { 'Food', 'Transport', etc. }
    Description: string[50]; { Brief description }
    Amount: Real;            { Expense amount }
  end;

All fields are fixed-length, making this record suitable for typed file storage.

SaveExpenses Procedure

procedure SaveExpenses(const FileName: string;
                       const Expenses: array of TExpenseRec;
                       Count: Integer);
var
  F: file of TExpenseRec;
  I: Integer;
  IOCode: Integer;
begin
  AssignFile(F, FileName);
  {$I-}
  Rewrite(F);
  IOCode := IOResult;
  {$I+}

  if IOCode <> 0 then
  begin
    WriteLn('Error: Could not create file "', FileName, '"');
    WriteLn('IOResult code: ', IOCode);
    Exit;
  end;

  for I := 0 to Count - 1 do
    Write(F, Expenses[I]);

  CloseFile(F);
  WriteLn('Saved ', Count, ' expenses to "', FileName, '".');
end;

LoadExpenses Function

function LoadExpenses(const FileName: string;
                      var Expenses: array of TExpenseRec;
                      var Count: Integer): Boolean;
var
  F: file of TExpenseRec;
  IOCode: Integer;
begin
  Result := False;
  Count := 0;

  if not FileExists(FileName) then
  begin
    WriteLn('No expense file found. Starting fresh.');
    Exit;
  end;

  AssignFile(F, FileName);
  {$I-}
  Reset(F);
  IOCode := IOResult;
  {$I+}

  if IOCode <> 0 then
  begin
    WriteLn('Error: Could not open "', FileName, '"');
    WriteLn('IOResult code: ', IOCode);
    Exit;
  end;

  while (not Eof(F)) and (Count <= High(Expenses)) do
  begin
    Read(F, Expenses[Count]);
    Inc(Count);
  end;

  CloseFile(F);
  WriteLn('Loaded ', Count, ' expenses from "', FileName, '".');
  Result := True;
end;

Integration into the Main Menu

const
  MAX_EXPENSES = 1000;
  DATA_FILE = 'pennywise.dat';

var
  Expenses: array[0..MAX_EXPENSES - 1] of TExpenseRec;
  ExpenseCount: Integer;
  Choice: Char;
begin
  ExpenseCount := 0;
  LoadExpenses(DATA_FILE, Expenses, ExpenseCount);

  repeat
    WriteLn;
    WriteLn('=== PennyWise Expense Tracker ===');
    WriteLn('[A] Add expense');
    WriteLn('[L] List expenses');
    WriteLn('[S] Summary by category');
    WriteLn('[V] Save');
    WriteLn('[Q] Quit');
    Write('Choice: ');
    ReadLn(Choice);
    Choice := UpCase(Choice);

    case Choice of
      'A': AddExpense(Expenses, ExpenseCount);
      'L': ListExpenses(Expenses, ExpenseCount);
      'S': SummaryByCategory(Expenses, ExpenseCount);
      'V': SaveExpenses(DATA_FILE, Expenses, ExpenseCount);
      'Q': begin
             SaveExpenses(DATA_FILE, Expenses, ExpenseCount);
             WriteLn('Goodbye! Your expenses are saved.');
           end;
    end;
  until Choice = 'Q';
end.

What You Have Accomplished

With this checkpoint, PennyWise now:

  1. Loads expenses from disk when it starts
  2. Allows the user to save at any time
  3. Automatically saves when quitting
  4. Handles missing files gracefully (starts with an empty list)
  5. Reports I/O errors to the user instead of crashing

This is the first time PennyWise has genuine utility as a personal finance tool. The data persists between sessions, which means users can track expenses over days, weeks, and months.

The Architecture of File-Based Persistence

Let us step back and appreciate the architecture. PennyWise now has three layers of data handling:

[User Input] → [In-Memory Array] → [On-Disk File]
                ↑ Load on startup   ↓ Save on command/quit

The in-memory array is the "working copy" — fast to access, easy to modify, but volatile. The on-disk file is the "persistent copy" — slower to access, but survives program termination. The LoadExpenses procedure copies data from disk to memory at startup. The SaveExpenses procedure copies data from memory to disk when requested.

This architecture — load into memory, work in memory, save back to disk — is the foundation of virtually every desktop application. Word processors, spreadsheets, image editors, and databases all follow this pattern. The details vary (databases use more sophisticated storage), but the concept is identical.

One important implication: PennyWise currently has no protection against data loss if the program crashes between saves. If Rosa enters ten expenses and the computer loses power before she saves, those ten expenses are gone. In Chapter 19, we will add auto-save and crash-safe writing to address this vulnerability. For now, the explicit save command is sufficient.

💡 Looking ahead: In Chapter 14, we will add sorting to PennyWise, so users can view expenses sorted by date, amount, or category. In later chapters, we will explore more sophisticated data management techniques.

📊 Spaced Review from Chapter 11: What is the WITH statement and when should you avoid it? The WITH statement lets you access record fields without repeating the record name: with Student do WriteLn(Name, Grade);. Avoid it when working with multiple records of the same type (ambiguity about which record's fields are being accessed) and when the scope is large (readability suffers because the reader cannot tell where the fields come from).


13.13 Chapter Summary

This chapter opened the door to persistent data — the ability for your programs to remember information between executions. Before this chapter, every program we wrote suffered from amnesia: brilliant in the moment, but forgetful by design. Now your programs can save their state, load it back, and survive the unpredictable reality of missing files and disk errors.

The skills you learned here are not specific to Pascal. Every programming language provides file I/O, and the concepts — sequential vs. random access, text vs. binary formats, error handling for unreliable operations, the load-modify-save lifecycle — transfer directly. When you learn Python's open() and with statements, or Java's FileInputStream and BufferedReader, or C's fopen and fread, you will recognize every pattern from this chapter wearing different syntax.

Let us review the key concepts:

Text Files are human-readable, line-oriented files. Open them with Reset (read), Rewrite (write), or Append (add to end). Read with ReadLn and write with WriteLn. They are ideal for configuration files, logs, and data exchange.

Typed Files (file of T) store data in binary format, mirroring the structure of a Pascal type. They support random access through Seek, FilePos, and FileSize. They are ideal for database-like record storage. Records must have fixed sizes — use string[N] rather than dynamic strings.

Untyped Files (file) provide raw byte-level access through BlockRead and BlockWrite. They are ideal for file copying, custom binary formats, and high-performance I/O.

Error Handling is non-negotiable for file I/O. Use {$I-} and IOResult to detect and respond to errors. Use FileExists for simple existence checks. Use try...finally to ensure files are always closed.

The File I/O Workflow is always the same: declare, assign, open, read/write, close. Never skip the close step.

Key Lessons

  1. Always close your files. An unclosed file risks data loss and resource leaks. The pattern is: open, try to use, always close — ideally with try..finally (Chapter 19).
  2. Always handle errors. File operations are inherently unreliable. Use IOResult or exceptions to detect and respond to failures.
  3. Choose the right file type. Text for human-readable data, typed for structured binary records, untyped for raw byte operations.
  4. Use fixed-length strings in typed files. Dynamic strings are pointers; writing a pointer to disk is meaningless. Always use string[N].
  5. Remember that Read advances the file position. After reading a record, the pointer is past it. To update in place, Seek back before writing.

What Comes Next

In Chapter 14, we tackle sorting algorithms — the ability to arrange data in meaningful order. Combined with files, sorting gives you the power to manage data professionally: load records from disk, sort them by any field, and save the sorted results. The combination of files, records, and sorting is the foundation of virtually every business application ever written.

Key Procedures and Functions at a Glance

Operation Text File Typed File Untyped File
Declare var F: TextFile; var F: file of T; var F: file;
Associate AssignFile(F, name) AssignFile(F, name) AssignFile(F, name)
Create Rewrite(F) Rewrite(F) Rewrite(F, recsize)
Open Reset(F) Reset(F) Reset(F, recsize)
Append Append(F) Seek(F, FileSize(F)) N/A
Write Write/WriteLn(F, ...) Write(F, rec) BlockWrite(F, buf, n)
Read Read/ReadLn(F, ...) Read(F, rec) BlockRead(F, buf, n, actual)
Random access No Seek(F, pos) Seek(F, pos)
Position N/A FilePos(F) FilePos(F)
Size N/A FileSize(F) FileSize(F)
Close CloseFile(F) CloseFile(F) CloseFile(F)
End check Eof(F), Eoln(F) Eof(F) Eof(F)

Files are the bridge between the ephemeral world of running programs and the permanent world of stored data. With the tools you have learned in this chapter, your programs can finally remember — and that changes everything. The next time you close your terminal and reopen it, your data will still be there. That simple fact transforms a program from a toy into a tool.