Reading a CSV touches a File, a Stream, a Reader, and a parser. Writing one runs the same four layers in reverse. That is not clutter — each layer does exactly one translation, and once the layers are named, code like new CsvWriter(new StreamWriter(new MemoryStream()), config) reads as a pipeline instead of a pile. This note draws the map the rest of the series fills in.
This is a ground-up series on .NET I/O — files, streams, readers, and writers. By the end, the kind of code that reads a CSV into objects and builds new CSVs back out as bytes will be fully understandable, every layer and every line.
This first part does not write much code. That is intentional. The single biggest mistake when learning I/O is treating FileStream, StreamReader, and CsvReader as interchangeable noise to be copied from Stack Overflow. They are not noise. Each one does a specific job, and once those jobs are clear, the rest of the series feels obvious instead of mysterious.
A file on disk is not text. It is a sequence of bytes — numbers from 0 to 255. A network connection carries bytes. Memory holds bytes. At the lowest level, the world outside a program only ever moves bytes.
A program wants meaning: a string like "Dune", a number like 1965, a Book object. Getting between raw bytes and meaning is not one step — it is two.
Reading walks left to right; writing walks right to left. Either way there are two separate problems. Bytes-to-characters is about encoding — which byte patterns mean which letters. Characters-to-objects is about parsing (or, going the other way, formatting) — what the comma-separated shape of a CSV actually means. .NET keeps these apart on purpose, because one object trying to do both at once would be welded to a single source and a single format forever.
The stack has four roles. Each has a reading form and a writing form:
| Layer | Reading form | Writing form | Job |
|---|---|---|---|
| Source / sink | a file, a string | a file, memory | hold or carry the raw bytes |
| Stream | FileStream | MemoryStream | move the data as bytes |
| Reader / Writer | StreamReader, StringReader | StreamWriter, StringWriter | translate bytes and text (encoding) |
| Domain parser | CsvReader | CsvWriter | translate text and objects |
The reading column turns a source into objects. The writing column turns objects into a sink. Same four responsibilities, pointed opposite ways.
Take a file books.csv:
title,author,genre
The Hobbit,J.R.R. Tolkien,Fantasy
Dune,Frank Herbert,Sci-FiReading it off disk runs top to bottom through every layer:
Each arrow is one translation. The FileStream neither knows nor cares what the bytes mean. The StreamReader turns bytes into text but has no idea the text is a CSV. The CsvReader reads text and knows nothing about disks. Every layer does its one job and hands off.
Building a CSV is the same stack in reverse. To produce a books.csv in memory:
In code, that stack is built inside-out — sink first, parser last:
var stream = new MemoryStream(); // the sink: an in-memory byte buffer
var writer = new StreamWriter(stream); // text -> bytes into that buffer
var csv = new CsvWriter(writer, configuration); // values -> CSV text
csv.WriteField("title");
csv.WriteField("author");
csv.WriteField("genre");
csv.NextRecord(); // end the header row
byte[] bytes = stream.ToArray(); // the finished CSV, as bytesThe CsvWriter formats, the StreamWriter encodes, the MemoryStream collects. Reading's CsvReader / StreamReader / FileStream map exactly onto writing's CsvWriter / StreamWriter / MemoryStream.
Two layers are pure adapters — they exist only to make in-memory data fit the same pipeline a file would use.
On the reading end, StringReader. A parser like CsvReader consumes text — specifically a TextReader, the abstraction for "characters can be read from this." A plain string is already characters, but it is not a TextReader, so the parser will not take it directly. StringReader wraps the string and presents it as a TextReader, letting a string enter the pipeline one layer down — skipping the file and the decode step entirely.
On the writing end, MemoryStream. Output has to flow into some stream, but not every result belongs in a file — sometimes the bytes are wanted in memory, to return from an API, attach to an email, or hand to another process. MemoryStream is a stream whose backing store is memory instead of disk, and ToArray() lifts the collected bytes out as a byte[].
Why the layers? The recurring question — "why can't I hand my string or my file path straight to the CSV parser, or get a byte array straight out of it?" — dissolves once the layers are named. A parser's job is text-to-objects, so its edge type is a text abstraction:
TextReaderin,TextWriterout. Anything that is not already text gets walked up to text first (a file becomes aFileStream, then aStreamReader); a string is already text and only needs aStringReaderto wear the right shape. Bytes come out at the bottom, from the stream —MemoryStream.ToArray()— never from the parser. The parser is never the layer that opens files or produces byte arrays.
The arrows suggest data flows straight through. Two realities complicate that, and each gets its own part later:
StreamWriter may sit in an internal buffer and not reach the MemoryStream yet. Asking the stream for its bytes before the writer has been flushed returns an incomplete result — which is why real write code calls Flush() before ToArray(). That is Part 4.using and Dispose(), in Part 5.StringReader and MemoryStream are adapters for the in-memory ends of the pipeline.Part 2 goes to the bottom of the stack — the Stream itself. What it actually is, the handful of operations it exposes (Read, Write, Position, Seek), and why FileStream, NetworkStream, and the MemoryStream the write side depends on are all interchangeable behind one abstraction. No new tools are needed — just the mental map built in this part.