Delimited numeric data looks easy until a parser accepts only the valid prefix of a damaged field. A value such as 19.95USD must not silently become 19.95, and a missing delimiter must not shift every field that follows it.

.NET 11 adds INumberBase<TSelf>.TryParsePartial, with overloads for strings, character spans, and UTF-8 spans. It returns the parsed number and the number of characters or bytes consumed. That makes it possible to advance through a larger buffer without creating a substring for every field. The important part is what happens next: the caller still has to validate the boundary immediately after the parsed prefix.

This article builds a strict semicolon-delimited parser around that contract, adds useful failure locations, and tests the cases that commonly turn a fast parser into a data-corruption bug.

What .NET 11 TryParsePartial changes

The existing TryParse methods expect the supplied input to represent one complete number. That is the right contract after another parser has already isolated a field. It is awkward when the numeric parser itself needs to stop at a delimiter inside a larger span.

TryParsePartial reports how much input belongs to the number:

ReadOnlySpan<char> input = "123;456";

bool parsed = int.TryParsePartial(
    input,
    NumberStyles.Integer,
    CultureInfo.InvariantCulture,
    out int value,
    out int charsConsumed);

// parsed == true, value == 123, charsConsumed == 3

The consumed count is a cursor, not proof that the field is valid. For 123x;456, a partial parse may still identify 123. A record parser must then require either the expected delimiter or the end of the record. This one check is the difference between deliberate prefix parsing and accidentally accepting garbage.

The API is defined by INumberBase<TSelf>, so the same parsing loop can work with int, decimal, double, BigInteger, and other numeric types that implement the interface. The overload for ReadOnlySpan<byte> provides the same pattern for UTF-8 pipelines and reports bytes consumed.

Build a boundary-checking field reader

The helper below parses one number and advances the caller’s span only after the complete field boundary has been validated:

using System.Globalization;
using System.Numerics;

public static class DelimitedNumberReader
{
    public static bool TryRead<T>(
        ref ReadOnlySpan<char> input,
        NumberStyles styles,
        char delimiter,
        bool finalField,
        out T value)
        where T : INumberBase<T>
    {
        ReadOnlySpan<char> original = input;

        if (!T.TryParsePartial(
                original,
                styles,
                CultureInfo.InvariantCulture,
                out value,
                out int consumed) ||
            consumed <= 0)
        {
            return false;
        }

        ReadOnlySpan<char> remainder = original[consumed..];

        if (finalField)
        {
            if (!remainder.IsEmpty)
            {
                return false;
            }
        }
        else
        {
            if (remainder.IsEmpty || remainder[0] != delimiter)
            {
                return false;
            }

            remainder = remainder[1..];
        }

        input = remainder;
        return true;
    }
}

The local original span keeps failure atomic: if parsing fails or the next character is not the expected delimiter, the caller’s cursor is unchanged. The finalField branch also rejects trailing text, including an extra delimiter. Passing NumberStyles explicitly makes each field’s grammar visible instead of applying one permissive style to every numeric type.

Invariant culture is intentional for a machine-owned wire format. It prevents the same bytes from changing meaning when the process runs under a different locale. If the format belongs to a person rather than a protocol, pass its documented culture deliberately and avoid using a comma both as decimal separator and field delimiter.

Parse a complete record without losing the failure location

Suppose an import contains product ID, unit price, and quantity:

1042;19.95;3

The record parser can reuse the field reader while naming the field that failed:

using System.Globalization;

public readonly record struct OrderLine(
    int ProductId,
    decimal UnitPrice,
    int Quantity);

public readonly record struct ParseError(
    string Field,
    int CharacterOffset);

public static class OrderLineParser
{
    public static bool TryParse(
        ReadOnlySpan<char> line,
        out OrderLine value,
        out ParseError error)
    {
        ReadOnlySpan<char> remaining = line;

        if (!DelimitedNumberReader.TryRead<int>(
                ref remaining,
                NumberStyles.Integer,
                ';',
                finalField: false,
                out int productId))
        {
            return Fail("productId", line, remaining, out value, out error);
        }

        if (!DelimitedNumberReader.TryRead<decimal>(
                ref remaining,
                NumberStyles.Number,
                ';',
                finalField: false,
                out decimal unitPrice))
        {
            return Fail("unitPrice", line, remaining, out value, out error);
        }

        if (!DelimitedNumberReader.TryRead<int>(
                ref remaining,
                NumberStyles.Integer,
                ';',
                finalField: true,
                out int quantity))
        {
            return Fail("quantity", line, remaining, out value, out error);
        }

        if (productId <= 0 || unitPrice < 0 || quantity <= 0)
        {
            value = default;
            error = new ParseError("businessRules", 0);
            return false;
        }

        value = new OrderLine(productId, unitPrice, quantity);
        error = default;
        return true;
    }

    private static bool Fail(
        string field,
        ReadOnlySpan<char> fullLine,
        ReadOnlySpan<char> remaining,
        out OrderLine value,
        out ParseError error)
    {
        value = default;
        error = new ParseError(field, fullLine.Length - remaining.Length);
        return false;
    }
}

Syntax and domain validation stay separate. The numeric parser answers whether the record is structurally valid; the final check answers whether the values make sense for this application. Do not hide domain rules inside a generic number reader, because another record type may legitimately allow zero or negative values.

The reported offset points to the beginning of the failing field because the helper advances the span only on success. That is usually enough for an import error such as line 814, unitPrice at character 5 without logging the full customer record.

Verify the parser against malformed boundaries

The happy path is the least interesting test. The verification suite should prove that valid prefixes do not escape the boundary check:

using System.Globalization;
using Xunit;

public sealed class OrderLineParserTests
{
    [Fact]
    public void Parses_a_complete_record()
    {
        Assert.True(OrderLineParser.TryParse(
            "1042;19.95;3",
            out OrderLine value,
            out ParseError error));

        Assert.Equal(new OrderLine(1042, 19.95m, 3), value);
        Assert.Equal(default(ParseError), error);
    }

    [Theory]
    [InlineData("1042x;19.95;3", "productId")]
    [InlineData("1042;19.95USD;3", "unitPrice")]
    [InlineData("1042;;3", "unitPrice")]
    [InlineData("1042;19.95;3;", "quantity")]
    [InlineData("1042;19.95;3x", "quantity")]
    [InlineData("999999999999999999999;19.95;3", "productId")]
    public void Rejects_invalid_or_partial_fields(
        string input,
        string expectedField)
    {
        Assert.False(OrderLineParser.TryParse(
            input,
            out _,
            out ParseError error));

        Assert.Equal(expectedField, error.Field);
    }

    [Fact]
    public void Machine_format_does_not_follow_current_culture()
    {
        CultureInfo previous = CultureInfo.CurrentCulture;
        try
        {
            CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE");

            Assert.True(OrderLineParser.TryParse(
                "1042;19.95;3",
                out OrderLine value,
                out _));

            Assert.Equal(19.95m, value.UnitPrice);
        }
        finally
        {
            CultureInfo.CurrentCulture = previous;
        }
    }
}

Run the suite with the .NET 11 SDK:

dotnet test

A useful mutation check is to delete the delimiter comparison in TryRead and confirm that the 1042x and 19.95USD cases fail the suite. That proves the tests protect the boundary rule rather than merely exercising the API.

The examples above are intended to compile against .NET 11. They were checked against the published Preview 7 API description and the runtime reference surface; no local execution result is claimed here.

Choose partial parsing only at the right layer

TryParsePartial is a good fit when your code owns the framing and already knows the next legal boundary. Examples include a compact telemetry protocol, a fixed record format, or a parser operating directly on a PipeReader buffer.

It is not a replacement for a CSV parser. CSV permits quoted fields, escaped quotes, and delimiters inside quoted text. Let a standards-aware CSV parser isolate those fields, then use ordinary TryParse on each complete numeric field. Likewise, do not use partial parsing to recover a number from arbitrary user text unless accepting a numeric prefix is the explicit product requirement.

For UTF-8 input, prefer the byte-span overload when the surrounding pipeline is already byte-oriented. Converting an entire payload to a string first gives up the allocation benefit. Keep offsets in one unit, however: a byte offset is not a character offset once non-ASCII data appears.

Production limits that still matter

Removing substrings does not remove the need for resource limits. Reject records above a documented maximum length before parsing them, cap the number of fields, and ensure every successful loop iteration consumes input. Those rules prevent oversized or malformed records from tying up a worker even when the parser allocates very little.

Decide how whitespace is represented. NumberStyles.Integer and NumberStyles.Number allow common leading and trailing whitespace; that may be convenient for imports and wrong for a canonical wire format. Tighten the styles or validate the raw field boundary if spaces must be rejected.

Treat overflow, an empty field, a malformed sign, and an unexpected delimiter as normal parse failures. Do not catch an exception and substitute zero. A default value can turn rejected input into a legitimate-looking order, which is much harder to diagnose than a failed row.

Finally, log metadata rather than raw records: line number, field name, offset, parser version, and a bounded error code. Import files often contain account identifiers or financial values, so copying the entire failed line into telemetry can create a second data-handling problem.

A safe adoption path

Start with one format whose grammar and culture are already documented. Add the strict boundary helper, port the existing malformed-input tests, and compare accepted and rejected row counts between the old and new implementations. Investigate every difference before enabling the new parser broadly.

Keep the old path behind a short-lived rollback switch if the import is operationally critical, but do not use fallback to reinterpret a row the strict parser rejected. Fallback is for implementation defects during rollout, not for silently weakening the data contract.

TryParsePartial supplies the missing cursor for allocation-conscious numeric parsing. The application still owns framing, culture, limits, and diagnostics. When those responsibilities remain explicit, the API can make a parser faster without making it more permissive.

References

Found this useful? Support more practical developer content.

Author

Practical .NET, Angular, Azure, Blazor, and AI engineering for real-world development.

Write A Comment

Ads Blocker Image Powered by Code Help Pro

Ads Blocker Detected!!!

We have detected that you are using extensions to block ads. Please support us by disabling these ads blocker.

Powered By
Best Wordpress Adblock Detecting Plugin | CHP Adblock