Last Updated: May 17, 2026
The .NET base class library ships two pairs of types for working with files and folders on disk. File and Directory are static classes full of one-shot helper methods. FileInfo and DirectoryInfo are instance classes that wrap a single path and cache information about it. This lesson covers what each type is for, when to pick the static helpers over the instance classes, how to enumerate large directory trees without running out of memory, and the small set of attribute and timestamp methods that come up constantly in real code. The examples here stick to existence checks, copying, moving, deleting, and walking the filesystem.
Every file or folder operation in System.IO boils down to a question the program asks the operating system: does this path exist, how big is it, copy it somewhere, list everything inside it, delete it. The .NET team gave you two API styles to ask those questions, and the choice between them is the first thing to get right.
The static API lives on the File and Directory classes. Every method takes the path as an argument and does its job in one call. Nothing is remembered between calls.
The instance API lives on FileInfo and DirectoryInfo. You construct one of these objects from a path, and then call methods and read properties on it. Each instance corresponds to one file or one folder.
The two snippets do the same job. The difference is what happens behind the scenes. Each call into File.Exists, File.GetLastWriteTime, or new FileInfo(path).Length is a fresh trip to the operating system to ask about that path. The OS has to walk the directory entry, check permissions, read the metadata, and return the result. That round trip is cheap by human standards (microseconds on a local SSD) but expensive when you do it thousands of times in a tight loop, and it gets slower on network drives or remote shares.
FileInfo and DirectoryInfo are different. When you construct one and then access properties like Length, LastWriteTime, Attributes, or Exists, the first access triggers one OS call that fills in all of them at once, and every later access reads from the cached values stored on the object. Five property reads cost one OS round trip, not five.
The trade-off is freshness. The cached values on a FileInfo are a snapshot taken at the moment the OS was first asked. If something else on the system modifies the file between two reads on the same FileInfo instance, the second read still sees the old values. Calling Refresh() forces the cache to repopulate.
| Question | Best fit | Why |
|---|---|---|
| Does this one file exist? | File.Exists | One question, one call. No object to construct. |
| How big is it, when was it modified, what are its attributes? | FileInfo | Three property reads from one cache fill. |
| Copy this file once. | File.Copy | One-shot operation, no metadata to cache. |
| Walk a folder of 10,000 files and print the size of each. | FileInfo per file | Each file's Length and LastWriteTime come from one OS call. |
| Delete a file. | File.Delete | One-shot operation. |
| Repeatedly check several properties of the same file across a long-running operation. | FileInfo with Refresh() calls when needed | Cache controls the round-trip count. |
A reasonable rule of thumb: if you're touching one piece of metadata about a path once, the static API is shorter and clearer. If you're touching multiple pieces of metadata about the same path, or doing it repeatedly in a loop, the instance API saves real work.
Cost: Every static File.* or Directory.* call is a separate trip to the OS. In a loop over thousands of paths, prefer one FileInfo per file and read its cached properties.
The File static class covers the lifecycle operations on a single file: existence checks, copying, moving, deleting, and reading or writing attributes and timestamps. Every method takes one or two paths as strings and returns either nothing or a small result.
File.Exists(path) returns true if the path refers to an existing file. It returns false if the path doesn't exist, if it refers to a directory instead of a file, or if the caller doesn't have permission to see it. It never throws for a missing file, which is unusual in System.IO. Most other methods throw if the file isn't there.
File.Copy(source, destination) duplicates the file at source into destination. The two-argument overload throws IOException if the destination already exists. The three-argument overload takes a boolean overwrite flag and silently replaces an existing destination when the flag is true. Copy does not delete the source. After it returns, both paths exist with identical content.
File.Move(source, destination) is the rename or relocate operation. On the same volume, it's effectively free because the OS just rewrites the directory entry. Across volumes, it has to copy the bytes and then delete the source, which can take real time for large files. A common surprise: classic File.Move throws if the destination exists. Since .NET 5, an overload accepts an overwrite flag like Copy does.
File.Delete(path) removes a file. If the path doesn't exist, the method returns silently rather than throwing, which matches Exists's behavior. If the path refers to a directory, it throws UnauthorizedAccessException. If the file is open in another process or read-only, it throws IOException.
There's no built-in "copy and overwrite if newer" or "move and rename on conflict" helper. You compose those out of Exists, Copy, Move, and the timestamp methods covered later in this lesson.
The same lifecycle operations exist on FileInfo as instance members. info.Exists, info.CopyTo(destination), info.MoveTo(destination), and info.Delete() do the same work, with the same exception rules, but operate on the cached path stored in the object. CopyTo returns a new FileInfo representing the destination, which is convenient for chained operations.
The static and instance versions are not interchangeable in style. The static API reads more naturally for a single one-shot operation. The instance API reads more naturally when the same file is being inspected and acted on across several lines, because the path lives on the object instead of being repeated as a string argument.
Every file on disk carries a small bundle of metadata beyond its content: a set of attribute flags (read-only, hidden, system, archive, and so on) and three timestamps (creation, last write, last access). The File and FileInfo types expose both, with the same static-versus-instance split as the rest of the API.
The attribute flags live in the FileAttributes enum, marked as [Flags] so values combine with bitwise operators. File.GetAttributes(path) returns the current flags as a single value. File.SetAttributes(path, attrs) writes them back.
Read the existing attributes first, OR in the new flag, and write the result back. Assigning FileAttributes.ReadOnly directly would erase the other flags. To clear a flag, AND with the bitwise complement: attrs & ~FileAttributes.ReadOnly.
The most useful flags in everyday code are ReadOnly, Hidden, and Directory. The last one is how you tell a file from a folder when you have only a path string: (File.GetAttributes(path) & FileAttributes.Directory) == FileAttributes.Directory is true for folders. There's also a convenience method, Directory.Exists(path), that's clearer to read for the same question.
The three timestamp methods are GetCreationTime, GetLastWriteTime, and GetLastAccessTime, each with a matching Set... counterpart. They all return DateTime in local time. The ...Utc variants return DateTime in UTC, which is the right choice for anything that gets stored, compared across time zones, or serialized.
LastWriteTime is the one that matters most often in real code. It's how you decide whether a cached export is stale, whether a downloaded copy is older than a remote source, or whether a backup ran in the last 24 hours. LastAccessTime is the most fragile of the three. Many filesystems disable last-access tracking for performance, so the value can be far behind reality. Do not build logic that depends on it being precise.
FileInfo exposes the same data as properties: info.Attributes, info.CreationTimeUtc, info.LastWriteTimeUtc, info.LastAccessTimeUtc. Reading any of them populates the cache for all of them in one OS call. Writing a property is a separate OS call.
A subtle point about FileInfo.Length: if the file doesn't exist when you read the property, it throws FileNotFoundException instead of returning zero or null. Always guard Length with an Exists check or wrap it in a try block when the file might be missing. This is unlike File.Exists, which never throws.
The Directory static class is the folder counterpart to File. The lifecycle methods follow the same shape: Exists, CreateDirectory, Delete, Move. The methods that look inside a folder are where Directory does most of its useful work: GetFiles, GetDirectories, EnumerateFiles, EnumerateDirectories, plus their Get/Enumerate FileSystemEntries siblings that return both kinds.
Directory.Exists(path) returns true if the path refers to an existing folder. Like File.Exists, it never throws. It returns false for a missing path, a path that points at a file, or a path the caller can't see.
Directory.CreateDirectory(path) is one of the friendlier methods in System.IO. If the folder already exists, it returns silently. If any part of the path is missing, it creates every level in one call: passing "data/exports/2024/q4" creates all four levels in order if none of them exist yet. The return value is a DirectoryInfo for the deepest folder, which is convenient for chained operations.
Directory.Delete(path) is stricter. With a single argument it only deletes empty folders; if the folder contains anything, it throws IOException. The two-argument overload Directory.Delete(path, recursive: true) deletes the folder and everything inside it. The recursive form is dangerous, treat it like rm -rf. There's no recycle bin involved, the deletion is final.
Listing what's inside a folder is the part of the API most programs care about. Four method pairs cover it.
| Method | Returns | When to use |
|---|---|---|
Directory.GetFiles(path) | string[] of file paths | Small folders where you want all results in memory at once. |
Directory.GetDirectories(path) | string[] of subfolder paths | Same, for subfolders only. |
Directory.EnumerateFiles(path) | IEnumerable<string> of file paths | Large folders where streaming results saves memory and lets you stop early. |
Directory.EnumerateDirectories(path) | IEnumerable<string> of subfolder paths | Same, for subfolders. |
GetFiles and EnumerateFiles look almost identical, and the difference is the most important performance choice in this lesson. GetFiles reads the entire directory listing into a string[] before returning. If the folder has 100,000 entries, the array holds 100,000 strings before the first one is processed. EnumerateFiles returns a lazy IEnumerable<string> that pulls entries from the OS one batch at a time. You can start processing the first result while the OS is still reading the rest, and if you break out of the loop early, the rest of the directory is never read.
The two methods take the same arguments. The first is the path. The second (optional) is a search pattern using * and ? wildcards. The third (also optional) is a SearchOption enum value: SearchOption.TopDirectoryOnly (the default) lists only the immediate folder, SearchOption.AllDirectories walks every subfolder recursively. There's also a fourth-argument EnumerationOptions overload added in .NET Core 2.1 with finer control (case sensitivity, whether to follow symlinks, max depth), but the three-argument form covers the usual cases.
Recursive enumeration with SearchOption.AllDirectories is convenient and easy to misuse. It walks the entire tree, including hidden folders, system folders, and anything the OS happens to expose under the starting path. Pointing it at the root of a drive will try to enumerate every file on the machine. Always start from a path you control.
Cost: GetFiles allocates an array sized to the full result set before you see the first entry. On a folder with 100,000 entries that's a sizable allocation plus the time to read every entry first. EnumerateFiles streams, so memory stays flat regardless of folder size.
Two diagrams help see the difference. First, the eager GetFiles flow.
The whole array is built before processing starts. The OS reads every entry up front, allocates one big string array, and only then does the loop see anything.
Now the lazy EnumerateFiles flow.
The loop and the OS take turns. Memory holds at most one batch of entries at a time, and a break statement stops the work immediately.
For folders smaller than a few hundred entries, the difference is negligible and GetFiles is fine because you can use array indexing, Length, LINQ over the array, and so on. For anything larger or anything where you might exit early, prefer EnumerateFiles.
The methods covered so far return file paths. Two more pairs cover the same ground for folders and for "anything in this directory."
Directory.GetDirectories(path) and Directory.EnumerateDirectories(path) return immediate subfolder paths. They take the same optional pattern and SearchOption arguments. With SearchOption.AllDirectories, they return every nested folder, useful for building a folder tree view or finding all folders matching a pattern.
The ?? pattern matches exactly two characters, so 20?? matches 2024 but not 20 or 20240. Wildcards in System.IO are simpler than glob or regex: * matches any sequence (including empty), ? matches a single character, and that's the whole language.
Directory.GetFileSystemEntries(path) and Directory.EnumerateFileSystemEntries(path) return both files and folders together, in one listing. The returned strings are just paths; the type is not encoded. To tell them apart, check File.GetAttributes(entry) for FileAttributes.Directory, or use Directory.Exists(entry) versus File.Exists(entry). These methods are useful when you want to walk one folder level and react differently to files versus subfolders.
The DirectoryInfo instance class is the counterpart to Directory and exposes the same operations as instance members. info.GetFiles() and info.EnumerateFiles() return arrays or sequences of FileInfo objects (not just path strings), which is the part that actually saves work in real code.
Compare that to the same loop with Directory.EnumerateFiles. The string-returning version forces you to call new FileInfo(path).Length for each entry, which is a separate construction and a separate OS call per file. DirectoryInfo.EnumerateFiles returns FileInfo objects whose metadata is already populated from the directory enumeration itself, so reading .Length on each one is free. On a folder of thousands of files, that's thousands of OS calls saved.
Cost: Directory.EnumerateFiles returns paths as strings, forcing a separate metadata lookup per file when you need size, dates, or attributes. DirectoryInfo.EnumerateFiles returns FileInfo objects with metadata already filled in. For loops that read metadata, the instance version is significantly cheaper.
The DirectoryInfo API also has GetDirectories() and EnumerateDirectories() returning DirectoryInfo objects, and GetFileSystemInfos() and EnumerateFileSystemInfos() returning the abstract base type FileSystemInfo, which both FileInfo and DirectoryInfo derive from. That base type is what lets you write code that uniformly walks a tree of mixed entries.
A small architecture diagram shows how the four types relate.
FileInfo and DirectoryInfo both inherit from FileSystemInfo, which is where shared properties like Name, FullName, Exists, Attributes, and the timestamp set live. The static File and Directory classes don't inherit from anything; they're just collections of helper methods that operate on path strings. Code that needs to treat files and folders uniformly can use the FileSystemInfo base type, while code that only deals with one or the other uses the more specific subclass.
Every method covered in this lesson takes a path as a string. Three small things are worth knowing here, with the full treatment saved for lesson 06.
First, paths can be relative or absolute. Relative paths are resolved against the current working directory of the process, which is Environment.CurrentDirectory. Two programs running in different folders can pass the same relative path and get different files. For anything that runs unattended, prefer absolute paths.
Second, the directory separator differs by OS. Windows uses \ (and accepts /), Linux and macOS use /. Hard-coding \ in a string literal works on Windows and breaks on Linux. The right way to build a path is Path.Combine("data", "exports", "2024.csv"), which uses the correct separator for the running OS.
Third, paths are not case-sensitive on Windows but are on Linux. Products.csv and products.csv are the same file on Windows and two different files on Linux. Anything that needs to behave the same on both has to either pick one casing convention or normalize before comparing.
Lesson 06 covers the Path class in detail, including Combine, GetExtension, GetFileName, GetDirectoryName, and the cross-platform considerations. For now, when an example uses Path.Combine, that's why.
Output (Linux/macOS):
Output (Windows):
Path.Combine doesn't normalize the separators inside a segment, it just inserts the right one between the segments it joins. That's good enough for almost all practical use; the OS APIs that consume the path accept either / or \ on Windows.
A small but realistic example pulls all of this together: a routine that archives every order JSON file older than 30 days into a year-stamped subfolder, then prints how much was archived.
Six things worth noticing in that snippet. The outer Directory.Exists guard is the no-throw, fast existence check. The DirectoryInfo plus EnumerateFiles combo streams FileInfo objects whose LastWriteTimeUtc and Length properties are already populated. LastWriteTimeUtc (not the local-time variant) is used so the cutoff comparison works correctly across daylight-saving boundaries. Path.Combine builds the destination path without hard-coding a separator. Directory.CreateDirectory is safe to call even if the year folder already exists. And file.MoveTo with overwrite: true handles the case where a previous run already archived a file with the same name.
That's the static-versus-instance choice in action. The cheap one-shot operations (Exists, CreateDirectory) use the static API. The repeated metadata reads on each file (Length, LastWriteTimeUtc) use the instance API. Picking the right one for each call keeps the OS round trips to the minimum the work actually needs.
File and Directory classes do one-shot operations against a path string. Each call is a separate trip to the OS, which is fine for occasional use and expensive in tight loops.FileInfo and DirectoryInfo wrap a single path and cache metadata. The first property read populates the cache from one OS call, and every later read is free, until Refresh() is called to repopulate.Directory.GetFiles returns a full string[] before processing starts. Directory.EnumerateFiles streams entries lazily and supports early termination, which matters on folders with thousands of entries or when only the first few results are needed.SearchOption.AllDirectories walks every subfolder recursively. It's convenient and easy to misuse; always start from a path the program controls.DirectoryInfo.EnumerateFiles returns FileInfo objects with metadata already populated by the directory enumeration. For loops that read size, dates, or attributes, it's significantly cheaper than Directory.EnumerateFiles followed by per-file new FileInfo(path) lookups.FileAttributes is a [Flags] enum, so SetAttributes writes the entire flag set. To add a flag without dropping the others, OR the new flag onto the result of GetAttributes. To clear a flag, AND with its bitwise complement.File.Exists and File.Delete never throw on a missing file. Most other methods do. Directory.Delete without the recursive flag throws on a non-empty folder; the recursive form deletes everything beneath the path permanently.Path.Combine to build paths instead of hard-coding / or \.