FixedWidth.Net 0.2.0

dotnet add package FixedWidth.Net --version 0.2.0
                    
NuGet\Install-Package FixedWidth.Net -Version 0.2.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="FixedWidth.Net" Version="0.2.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="FixedWidth.Net" Version="0.2.0" />
                    
Directory.Packages.props
<PackageReference Include="FixedWidth.Net" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add FixedWidth.Net --version 0.2.0
                    
#r "nuget: FixedWidth.Net, 0.2.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package FixedWidth.Net@0.2.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=FixedWidth.Net&version=0.2.0
                    
Install as a Cake Addin
#tool nuget:?package=FixedWidth.Net&version=0.2.0
                    
Install as a Cake Tool

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 fluent Schema.For<T>() builder for types you cannot annotate
  • The bank-file idioms as first-class options: implied decimals ("0000012345" with 2 implied decimals is 123.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; double and float are rejected at schema build because binary floating point corrupts money
  • Multi-record files (header, detail, trailer) through a RecordSet that 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 FixedWidthException carrying the one-based record number, field name, raw field text, and target type; never a bare FormatException
  • 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 = false if an all-space line is a legitimate record for you).
  • Line lengths are validated against the schema: Exact (default) throws on any mismatch, AllowShorter tolerates short lines, AllowLonger ignores 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), unsigned (the NACHA idiom, where direction lives in a transaction code and a minus sign anywhere is an error), or COBOL zoned-decimal ASCII overpunch, where the sign is folded into the last digit:

using FixedWidth;

// A COBOL PIC S9(5) field: five zero-filled digits with the sign overpunched on the last.
var schema = Schema.For<Ledger>()
    .Field(x => x.Balance, 0, 5, f => f.Sign(SignConvention.ZonedOverpunch).Pad('0'))
    .Build();

// Read: "1234M" is -12344 ('M' is -4), "1234{" is +12340 ('{' is +0), "123I" is +1239 ('I' is +9).
var balance = FixedWidthReader.Read("1234M", schema).Single().Balance; // -12344

// Write: +12340 becomes "1234{", -12344 becomes "1234M", 0 becomes "0000{".
var text = FixedWidthWriter.Write(
    [new Ledger { Balance = -12344 }], schema, new WriteOptions { TrailingNewLine = false }); // "1234M"

Positive digits 0-9 map to { A B C D E F G H I, negative digits to } J K L M N O P Q R; the remaining digits stay plain 0-9, and the field is always zero-filled right-justified so the sign lands on a real digit.

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 to TrimMode.Both; use TrimMode.None to preserve raw text.
  • TrimMode.Both here 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 as 00-12345 (which it then cannot re-read), while this library writes -0012345 and 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.2:

  • No master-detail hierarchies: RecordSet gives 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 Stream overloads decode UTF-8 only; anything else, you decode.
  • Positional records without a parameterless constructor are not supported; give the type a parameterless constructor and init setters.
  • Enum fields parse by name (case-insensitive) or number and write by name; [Flags] combinations are not supported.
  • COBOL zoned-decimal ASCII overpunch signs are supported via SignConvention.ZonedOverpunch (see Money discipline). The EBCDIC overpunch code page (where the same signs sit at different byte values) is not decoded; decode EBCDIC to ASCII upstream, then apply ZonedOverpunch.
  • Two-digit year formats (yyMMdd) resolve through DateOnly.ParseExact under the invariant culture, which currently pivots at 2049: 00-49 mean 2000-2049, 50-99 mean 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • 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.

Version Downloads Last Updated
0.2.0 44 8/21/2026
0.1.0 100 8/4/2026