FixedWidth.Net
0.1.0
See the version list below for details.
dotnet add package FixedWidth.Net --version 0.1.0
NuGet\Install-Package FixedWidth.Net -Version 0.1.0
<PackageReference Include="FixedWidth.Net" Version="0.1.0" />
<PackageVersion Include="FixedWidth.Net" Version="0.1.0" />
<PackageReference Include="FixedWidth.Net" />
paket add FixedWidth.Net --version 0.1.0
#r "nuget: FixedWidth.Net, 0.1.0"
#:package FixedWidth.Net@0.1.0
#addin nuget:?package=FixedWidth.Net&version=0.1.0
#tool nuget:?package=FixedWidth.Net&version=0.1.0
FixedWidth.NET
Fixed-width and positional flat-file parsing and writing for .NET. Attribute or fluent schemas, implied decimals as decimal, multi-record files via a discriminator, lazy and async reading, fail-loud writing. Zero external dependencies.
Fixed-width is the native tongue of bank and mainframe files: NACHA ACH batches, BAI2 balance reports, card-scheme clearing files, payroll exports, government submissions. The nightly file on the SFTP server did not stop being positional because CSV libraries got good. Meanwhile the .NET parsers for it have died: FileHelpers (23.7 million downloads) last shipped in 2022 and carries over a hundred open issues on a reflection-heavy core; FlatFiles (1.9 million downloads) was archived in March 2026 with a farewell note; Microsoft's answer is Microsoft.VisualBasic.FileIO.TextFieldParser. Nothing in the category is Span-based. This library is a maintained replacement: strict about money, explicit about layout, and loud about anything that does not fit.
What it gives you:
- Two schema styles, one engine:
[FixedWidthRecord]and[Field(start, length)]attributes for your own types, a fluentSchema.For<T>()builder for types you cannot annotate - The bank-file idioms as first-class options: implied decimals (
"0000012345"with 2 implied decimals is123.45m), zero padding, right alignment, leading or trailing minus, NACHA-style unsigned fields, full-width null sentinels string,char,bool,int,long,short,decimal,DateOnly,DateTime,TimeOnly, enums, and their nullable forms;doubleandfloatare rejected at schema build because binary floating point corrupts money- Multi-record files (header, detail, trailer) through a
RecordSetthat dispatches on a type code at a fixed position, and stamps that code back when writing - Reading that is lazy (
IEnumerable<T>), async (IAsyncEnumerable<T>), or collecting (ReadResult<T>with per-record errors instead of exceptions) - Writing that enforces exact field lengths: a value that does not fit throws with the record number and field name, never truncates
- CRLF, LF, and lone-CR line endings; a final line without a newline; blank-line skipping you can turn off; newline-free fixed-length blocks via
ReadOptions.RecordLength - Every failure is a typed
FixedWidthExceptioncarrying the one-based record number, field name, raw field text, and target type; never a bareFormatException - Span-based field slicing, one cached compiled schema per type, linear-time parsing (about 4.5 million records per second on the settlement layout below), invariant culture everywhere
Install
dotnet add package FixedWidth.Net
Quickstart
Parse a bank settlement file where amounts carry two implied decimals:
using System;
using FixedWidth;
var content =
"INV-2026-0071 00000035002520260701\r\n" +
"INV-2026-0072 00001200000020260702\r\n";
foreach (var settlement in FixedWidthReader.Read<Settlement>(content))
{
Console.WriteLine($"{settlement.Reference}: {settlement.Amount} on {settlement.ValueDate}");
}
[FixedWidthRecord]
public sealed class Settlement
{
[Field(0, 16)]
public string Reference { get; set; } = "";
[Field(16, 12, ImpliedDecimals = 2, Padding = '0')]
public decimal Amount { get; set; }
[Field(28, 8, Format = "yyyyMMdd")]
public DateOnly ValueDate { get; set; }
}
"000000350025" becomes 3500.25m. Starts are zero-based, lengths are explicit, and the schema is validated once per type and cached: overlapping fields, unsupported types, and misconfigured options fail at first use with a message naming the property, not at record 40,000 with a mystery.
For types you cannot annotate, the fluent builder produces the same schema:
using System.Linq;
using FixedWidth;
var schema = Schema.For<Trade>()
.Field(x => x.Reference, 0, 16)
.Field(x => x.Amount, 16, 12, f => f.ImpliedDecimals(2).AlignRight().Pad('0'))
.Build();
var trade = FixedWidthReader.Read("FX-DEAL-812 000000992500", schema).Single();
public sealed class Trade
{
public string Reference { get; set; } = "";
public decimal Amount { get; set; }
}
Multi-record files
Real bank files are not one record type. A NACHA-style file mixes headers, entries, and controls, discriminated by a type code at a fixed position, and every record is 94 characters wide. Cover the full record width in each mapped type (Reserved fields for the columns you do not care about), map the codes once, and pattern match on the stream:
using FixedWidth;
var set = RecordSet.Create()
.Discriminator(0, 1)
.Map<FileHeader>("1")
.Map<EntryDetail>("6")
.Map<FileControl>("9")
.Build();
var options = new ReadOptions { LineLengthMode = LineLengthMode.AllowLonger };
var total = 0m;
foreach (var record in set.Read(content, options))
{
switch (record)
{
case EntryDetail detail:
total += detail.Amount;
break;
case FileControl control when control.TotalAmount != total:
throw new InvalidOperationException("File control total does not match the entries.");
}
}
[FixedWidthRecord]
public sealed class FileHeader
{
[Field(1, 30)]
public string Company { get; set; } = "";
[Field(31, 6, Format = "yyMMdd")]
public DateOnly CreationDate { get; set; }
[Field(37, 57)]
public string Reserved { get; set; } = "";
}
[FixedWidthRecord]
public sealed class EntryDetail
{
[Field(1, 10, ImpliedDecimals = 2, Padding = '0', Sign = SignConvention.Unsigned)]
public decimal Amount { get; set; }
[Field(11, 25)]
public string ReceiverName { get; set; } = "";
[Field(36, 58)]
public string Reserved { get; set; } = "";
}
[FixedWidthRecord]
public sealed class FileControl
{
[Field(1, 12, ImpliedDecimals = 2, Padding = '0', Sign = SignConvention.Unsigned)]
public decimal TotalAmount { get; set; }
[Field(13, 81)]
public string Reserved { get; set; } = "";
}
The AllowLonger option is there because real exports routinely carry trailing padding or extra characters beyond the documented width; with the schema covering all 94 columns, anything past them is ignored rather than fatal. Two things to know before feeding this a production ACH file: cover the full record width in each mapped type, and note that NACHA files are padded to a block multiple with all-9s filler lines, which parse as whatever type "9" maps to until the typed NACHA schema pack lands (see the roadmap); filter them on their all-9s content for now.
set.Read returns the records as objects in file order; keep the file's own sequence and dispatch with pattern matching. set.Write goes the other way and stamps each record's discriminator code into its columns, so record types do not need a property for the code (if a mapped field covers those columns, the stamped code wins). Note the fields start at column 1: column 0 belongs to the discriminator.
Writing
Writing applies padding and alignment per schema and enforces exact lengths. A value that does not fit throws with the record number and field name; nothing is ever silently truncated:
using FixedWidth;
var text = FixedWidthWriter.Write(new[]
{
new Settlement
{
Reference = "INV-2026-0071",
Amount = 3500.25m,
ValueDate = new DateOnly(2026, 7, 1),
},
});
// "INV-2026-0071 00000035002520260701\r\n"
WriteOptions controls the separator: "\r\n" by default, "\n" if you prefer, or "" to produce a newline-free positional block. FixedWidthWriter.WriteAsync writes to a TextWriter or a Stream (UTF-8).
Errors: throw or collect
The default is fail-loud: the first bad record throws a FixedWidthException with the record number, field name, raw field text, and target type. For quarantine-and-continue pipelines, switch to Skip and read into a report:
using FixedWidth;
var result = FixedWidthReader.ReadAll<Settlement>(
content,
new ReadOptions { ErrorBehavior = ErrorBehavior.Skip });
foreach (var error in result.Errors)
{
Console.WriteLine($"record {error.RecordNumber}, field {error.FieldName}: {error.Message}");
}
ReadResult<T> carries the parsed Records and the failed ones as FixedWidthError values, each with the raw record text for logging or reprocessing. The lazy Read overloads skip silently under Skip; use ReadAll when you want the report.
Line handling
- CRLF, LF, and lone-CR line endings all work, mixed freely in one file; a final line without a newline is a record.
- Blank lines are skipped by default (
ReadOptions.SkipBlankLines = falseif an all-space line is a legitimate record for you). - Line lengths are validated against the schema:
Exact(default) throws on any mismatch,AllowShortertolerates short lines,AllowLongerignores extra characters. - Under
AllowShorter, a missing tail is treated as each field's padding for text-like fields only (right-aligned fields keep their alignment: the present characters anchor to the right edge). Numeric fields are never synthesized: a truncation that touches an int, long, short, or decimal field fails loudly for that record, because "12" padded out to "12000000" is twelve million, not twelve. - Mainframe-style blocks without newlines parse via
ReadOptions.RecordLength:
using System.Linq;
using FixedWidth;
var block = "INV-2026-0071 00000035002520260701" + "INV-2026-0072 00001200000020260702";
var records = FixedWidthReader.Read<Settlement>(
block,
new ReadOptions { RecordLength = 36 }).ToList();
The library operates on characters; the caller owns byte decoding. The Stream conveniences decode UTF-8 and say so; for EBCDIC or code pages, decode first and hand over a TextReader. Field starts and lengths are measured in UTF-16 code units, not bytes or grapheme clusters: an emoji counts as 2, and writing never splits a surrogate pair because an overflowing value throws instead of being truncated.
Money discipline
Every numeric conversion runs under the invariant culture, and decimal is the money type: double and float properties are rejected when the schema builds, with a message telling you to use decimal. Implied-decimal fields refuse to write a value with more precision than the field encodes (1.005m into a 2-implied-decimal field throws instead of rounding). Sign conventions are explicit per field: leading minus (default), trailing minus (the mainframe ledger idiom), or unsigned (the NACHA idiom, where direction lives in a transaction code and a minus sign anywhere is an error).
Migrating from FileHelpers
The concepts map one to one; the main difference is that fields declare (start, length) instead of sequential widths, so record layouts read like the file specification they came from.
| FileHelpers | FixedWidth.Net |
|---|---|
[FixedLengthRecord] |
[FixedWidthRecord] |
[FieldFixedLength(10)] (sequential) |
[Field(start, 10)] (explicit position) |
[FieldTrim(TrimMode.Both)] |
[Field(..., TrimMode = TrimMode.Both)] (the default) |
[FieldAlign(AlignMode.Right, '0')] |
[Field(..., Alignment = FieldAlignment.Right, Padding = '0')] |
[FieldConverter(ConverterKind.Date, "yyyyMMdd")] |
[Field(..., Format = "yyyyMMdd")] on DateOnly/DateTime |
[FieldConverter(ConverterKind.Decimal)] plus custom converter for implied decimals |
[Field(..., ImpliedDecimals = 2)] |
[FieldNullValue(...)] (substitute default) |
[Field(..., NullValue = "000000")] (real null via T?) |
[FieldOptional] |
ReadOptions.LineLengthMode = LineLengthMode.AllowShorter |
new FileHelperEngine<T>().ReadString(text) |
FixedWidthReader.Read<T>(text) (lazy) |
engine.WriteString(records) |
FixedWidthWriter.Write(records) |
MultiRecordEngine with a type-selector delegate |
RecordSet.Create().Discriminator(...).Map<T>(code) |
ErrorMode.SaveAndContinue |
ErrorBehavior.Skip + ReadAll returning ReadResult<T> |
Record classes need a public parameterless constructor and public settable properties (set or init), same as FileHelpers.
Behavioral differences from FileHelpers worth knowing before you flip a pipeline over:
- Trimming defaults differ: FileHelpers does not trim unless you add
[FieldTrim], while fields here default toTrimMode.Both; useTrimMode.Noneto preserve raw text. TrimMode.Bothhere trims the field's configured padding character, not whitespace in general, so a'*'-padded field trims asterisks.- FileHelpers silently truncates a value that overflows its field on write; this library throws with the record number and field name, so a pipeline that quietly relied on truncation now fails loudly.
- Negative zero-padded numbers: FileHelpers writes 12345 with
AlignMode.Right, '0'on a negative value as00-12345(which it then cannot re-read), while this library writes-0012345and loudly rejects the FileHelpers form on read.
Performance
Parsing is Span-based over the input with one substring per field and no per-record reflection: property access goes through compiled expression-tree accessors built once per type and cached. On the 36-character settlement layout above, a mid-range machine parses about 4.5 million records per second (roughly 165 MB per second) and writes about 1.8 million per second, scaling linearly with input size; the test suite asserts the linearity.
On NativeAOT: the library compiles under AOT, but compiled expression trees fall back to the interpreter there, so property access is slower; parsing still works, attribute and fluent paths alike. A source generator for reflection-free, full-speed AOT is on the roadmap.
Limitations and roadmap
Honest constraints in 0.1:
- No master-detail hierarchies:
RecordSetgives you the flat record stream in file order; grouping details under their header is your loop for now. - No encoding detection: the library consumes chars. The
Streamoverloads decode UTF-8 only; anything else, you decode. - Positional records without a parameterless constructor are not supported; give the type a parameterless constructor and
initsetters. - Enum fields parse by name (case-insensitive) or number and write by name;
[Flags]combinations are not supported. - EBCDIC overpunch and zoned-decimal sign digits (
"12345{"for +123.45,"12345}"for -123.45) are not decoded in 0.1; they fail loudly as invalid numbers, so decode them upstream or wait for the NACHA/BAI2 schema packs. - Two-digit year formats (
yyMMdd) resolve throughDateOnly.ParseExactunder the invariant culture, which currently pivots at 2049:00-49mean 2000-2049,50-99mean 1950-1999.
Roadmap: a source generator for true AOT and zero-reflection startup, master-detail grouping, and typed schema packs for NACHA and BAI2 on top of the engine.
License
MIT. See LICENSE.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.