Mert Özen Further With Every Line
Backend LINQ — #14

LINQ with Real Data: Scenarios on Orders Read from a JSON File

Mert Özen Nov 7, 2026 12 min 2 views
LINQ with Real Data: Scenarios on Orders Read from a JSON File

LINQ over orders read from a file with System.Text.Json: the casing trap and JsonSerializerDefaults.Web, validating the data (duplicates, unknown customers, bad quantities), monthly revenue, best sellers, a customer report with GroupJoin, writing JSON back and streaming with DeserializeAsyncEnumerable.

Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.

Guess First

In this post we use LINQ on order data read from a file instead of sample lists. The file is a typical JSON dump taken from an e-commerce system; the first order looks like this:

{ "id": 1001, "customerId": 1, "orderedAt": "2026-08-03T10:15:00Z", "items": [
  { "isbn": "978-0132350884", "title": "Clean Code", "quantity": 2, "unitPrice": 37.90 },
  { "isbn": "978-0134757599", "title": "Refactoring", "quantity": 1, "unitPrice": 47.00 } ] }

Let's read the file the shortest way possible. The program compiles, there are no warnings and it doesn't throw at runtime either. What do you think the three lines print?

using System.Text.Json;

string json = File.ReadAllText("orders.json");
List<Order> orders = JsonSerializer.Deserialize<List<Order>>(json)!;

Console.WriteLine($"Orders: {orders.Count}");
Console.WriteLine($"First id: {orders[0].Id}, customer: {orders[0].CustomerId}");
Console.WriteLine($"Items is null: {orders[0].Items is null}");

public record OrderItem(string Isbn, string Title, int Quantity, decimal UnitPrice);
public record Order(int Id, int CustomerId, DateTime OrderedAt, List<OrderItem> Items);

The Answer: Fields That Come Back Empty, Silently

Orders: 10
First id: 0, customer: 0
Items is null: True

All ten orders were read, but every one of them has an Id of 0 and Items set to null. By default System.Text.Json matches property names case-sensitively. The id in the JSON didn't match Id on the C# side; the serializer skipped the unmatched fields without complaint and passed the default value for every parameter of the record's constructor. On top of that, Items is declared as a non-nullable List<OrderItem>. Nullable reference types are a compile-time warning system; at runtime they don't stop a null coming from JSON. You'd get a NullReferenceException on the first o.Items.Sum(...) call, far away from where you read the file.

The fix is one line: new JsonSerializerOptions(JsonSerializerDefaults.Web). This preset follows the habits of web APIs: it writes property names in camelCase and ignores casing when reading. ASP.NET Core uses the same defaults in its own JSON settings. Create these options once and reuse the same instance everywhere; creating a new JsonSerializerOptions on every call makes the serializer prepare its type metadata from scratch every time.

The Data and the Model

The rest of the post works through a single Program.cs. Put the sections below one after another and you get the working program; the type declarations come after the top-level statements, at the very end of the file. Here is the order file we'll use:

[
  { "id": 1001, "customerId": 1, "orderedAt": "2026-08-03T10:15:00Z", "items": [
    { "isbn": "978-0132350884", "title": "Clean Code", "quantity": 2, "unitPrice": 37.90 },
    { "isbn": "978-0134757599", "title": "Refactoring", "quantity": 1, "unitPrice": 47.00 } ] },
  { "id": 1002, "customerId": 2, "orderedAt": "2026-08-14T16:40:00Z", "items": [
    { "isbn": "978-0321125217", "title": "Domain-Driven Design", "quantity": 1, "unitPrice": 54.00 } ] },
  { "id": 1003, "customerId": 3, "orderedAt": "2026-08-31T22:30:00Z", "items": [
    { "isbn": "978-0135957059", "title": "The Pragmatic Programmer", "quantity": 1, "unitPrice": 42.50 },
    { "isbn": "978-0132350884", "title": "Clean Code", "quantity": 1, "unitPrice": 37.90 } ] },
  { "id": 1004, "customerId": 1, "orderedAt": "2026-09-05T09:00:00Z", "items": [
    { "isbn": "978-0201633610", "title": "Design Patterns", "quantity": 1, "unitPrice": 49.90 } ] },
  { "id": 1005, "customerId": 5, "orderedAt": "2026-09-18T12:20:00Z", "items": [
    { "isbn": "978-0132350884", "title": "Clean Code", "quantity": 3, "unitPrice": 37.90 } ] },
  { "id": 1006, "customerId": 99, "orderedAt": "2026-09-21T08:05:00Z", "items": [
    { "isbn": "978-0135957059", "title": "The Pragmatic Programmer", "quantity": 1, "unitPrice": 42.50 } ] },
  { "id": 1007, "customerId": 2, "orderedAt": "2026-10-02T14:00:00Z", "items": [
    { "isbn": "978-0134757599", "title": "Refactoring", "quantity": 2, "unitPrice": 47.00 },
    { "isbn": "978-0321125217", "title": "Domain-Driven Design", "quantity": -1, "unitPrice": 54.00 } ] },
  { "id": 1008, "customerId": 3, "orderedAt": "2026-10-11T19:45:00Z", "items": [
    { "isbn": "978-0321125217", "title": "Domain-Driven Design", "quantity": 1, "unitPrice": 54.00 },
    { "isbn": "978-0201633610", "title": "Design Patterns", "quantity": 1, "unitPrice": 49.90 } ] },
  { "id": 1008, "customerId": 3, "orderedAt": "2026-10-11T19:45:00Z", "items": [
    { "isbn": "978-0321125217", "title": "Domain-Driven Design", "quantity": 1, "unitPrice": 54.00 },
    { "isbn": "978-0201633610", "title": "Design Patterns", "quantity": 1, "unitPrice": 49.90 } ] },
  { "id": 1009, "customerId": 5, "orderedAt": "2026-10-27T11:10:00Z", "items": [
    { "isbn": "978-0135957059", "title": "The Pragmatic Programmer", "quantity": 2, "unitPrice": 42.50 } ] }
]

This file has three deliberate flaws; we'll find them with LINQ shortly. The customer file is shorter:

[
  { "id": 1, "name": "Ada Lovelace", "city": "London" },
  { "id": 2, "name": "Alan Turing", "city": "Manchester" },
  { "id": 3, "name": "Grace Hopper", "city": "New York" },
  { "id": 4, "name": "Linus Torvalds", "city": "Helsinki" },
  { "id": 5, "name": "Margaret Hamilton", "city": "Boston" }
]

The types are records. Total is a property that isn't in the JSON and is computed from the order's lines; the serializer ignores it when reading:

public record Customer(int Id, string Name, string City);

public record OrderItem(string Isbn, string Title, int Quantity, decimal UnitPrice);

public record Order(int Id, int CustomerId, DateTime OrderedAt, List<OrderItem> Items)
{
    public decimal Total => Items.Sum(i => i.Quantity * i.UnitPrice);
}

public record CustomerReport(string Name, string City, int Orders, decimal Revenue);

The reading part is async. DeserializeAsync reads the file through a stream; it doesn't load the whole file into a string first. Since await can be used directly in top-level statements, we don't write a separate Main:

using System.Globalization;
using System.Text.Json;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);

List<Order> orders = await LoadAsync<Order>("orders.json", options);
List<Customer> customers = await LoadAsync<Customer>("customers.json", options);

Console.WriteLine($"Loaded {orders.Count} orders and {customers.Count} customers");
Console.WriteLine($"First order: {orders[0].Id}, {orders[0].Items.Count} items, {orders[0].OrderedAt:u}");

static async Task<List<T>> LoadAsync<T>(string path, JsonSerializerOptions options)
{
    await using FileStream stream = File.OpenRead(path);
    return await JsonSerializer.DeserializeAsync<List<T>>(stream, options) ?? new List<T>();
}
Loaded 10 orders and 5 customers
First order: 1001, 2 items, 2026-08-03 10:15:00Z

We deliberately set CultureInfo.CurrentCulture to InvariantCulture. The numbers in the reports are formatted with N2, and under the tr-TR culture 257.20 would be written as 257,20. If you don't want the output to depend on the machine, choose the culture explicitly.

Validate the Data First

The first rule of working with real data is not to assume it's clean. An export may have run twice, an order may belong to a deleted customer, a return line may have been entered with a negative quantity. Before producing any report, let's weed those out with LINQ:

HashSet<int> customerIds = customers.Select(c => c.Id).ToHashSet();

List<int> duplicateIds = orders
    .GroupBy(o => o.Id)
    .Where(g => g.Count() > 1)
    .Select(g => g.Key)
    .ToList();

List<int> unknownCustomer = orders
    .Where(o => !customerIds.Contains(o.CustomerId))
    .Select(o => o.Id)
    .ToList();

List<int> badQuantity = orders
    .Where(o => o.Items.Any(i => i.Quantity <= 0))
    .Select(o => o.Id)
    .ToList();

List<Order> valid = orders
    .DistinctBy(o => o.Id)
    .Where(o => customerIds.Contains(o.CustomerId))
    .Where(o => o.Items.All(i => i.Quantity > 0))
    .ToList();

Console.WriteLine($"Duplicate ids: {string.Join(", ", duplicateIds)}");
Console.WriteLine($"Unknown customer: {string.Join(", ", unknownCustomer)}");
Console.WriteLine($"Bad quantity: {string.Join(", ", badQuantity)}");
Console.WriteLine($"Valid orders: {valid.Count} of {orders.Count}");
Duplicate ids: 1008
Unknown customer: 1006
Bad quantity: 1007
Valid orders: 7 of 10

We first put the customer Ids into a HashSet<int>. Contains will be called for every order, and on a HashSet that takes constant time; had you written the same check as customers.Any(c => c.Id == o.CustomerId), you'd scan the customer list from start to end for every order. We found the duplicate order with GroupBy, and for the cleanup we used DistinctBy, which came with .NET 6: it keeps the first item for each Id and drops the rest.

We didn't just drop the bad records, we listed them. In a real system those lists go to a log or an alert email. Data dropped silently comes back months later as the question "why doesn't the revenue in the report match accounting?". Every report from here on uses the valid list; we materialized it with ToList() so that the validation query doesn't run again for each report.

Monthly Revenue

The first question a manager will ask: how much did we sell each month?

var monthly = valid
    .GroupBy(o => new { o.OrderedAt.Year, o.OrderedAt.Month })
    .OrderBy(g => g.Key.Year)
    .ThenBy(g => g.Key.Month)
    .Select(g => new
    {
        Month = $"{g.Key.Year}-{g.Key.Month:D2}",
        Orders = g.Count(),
        Revenue = g.Sum(o => o.Total),
    });

foreach (var m in monthly)
    Console.WriteLine($"{m.Month}  {m.Orders} orders  {m.Revenue,7:N2}");
2026-08  3 orders   257.20
2026-09  2 orders   163.60
2026-10  2 orders   188.90

The grouping key is an anonymous type with two fields. Anonymous types are compared by value equality, so orders from the same year and month land in the same group. We sorted by the key, not by the text; strings like "2026-08" would also sort correctly in this example, but sorting by numeric fields doesn't depend on formatting. Money calculations use decimal; 37.90 in the JSON is read into a decimal exactly, without the rounding surprises of double.

Best-Selling Books

Orders carry lines, not books. First we need to flatten all the lines into a single sequence; that's what SelectMany is for:

var topSellers = valid
    .SelectMany(o => o.Items)
    .GroupBy(i => i.Isbn)
    .Select(g => new
    {
        Title = g.First().Title,
        Quantity = g.Sum(i => i.Quantity),
        Revenue = g.Sum(i => i.Quantity * i.UnitPrice),
    })
    .OrderByDescending(x => x.Quantity)
    .ThenByDescending(x => x.Revenue)
    .Take(3);

foreach (var book in topSellers)
    Console.WriteLine($"{book.Title,-26} {book.Quantity}  {book.Revenue,7:N2}");
Clean Code                 6   227.40
The Pragmatic Programmer   3   127.50
Domain-Driven Design       2   108.00

We grouped by ISBN, not by title. Title text can vary from system to system; the same book may arrive as "Domain-Driven Design" in one record and "Domain Driven Design" in another. Use the field that doesn't change as the identity, and take the title for display from the group's first element.

The ThenByDescending line isn't decoration. Domain-Driven Design and Design Patterns both sold two copies. Without a second ordering rule, which of them makes third place would depend on the order of the orders in the file. Decide yourself what happens on a tie, or one day your report will change for no apparent reason.

The Customer Report and the Left Join

Order count and revenue per customer. The thing to watch here is that customers who never ordered also show up in the report:

List<CustomerReport> report = customers
    .GroupJoin(valid,
        c => c.Id,
        o => o.CustomerId,
        (c, customerOrders) => new CustomerReport(
            c.Name,
            c.City,
            customerOrders.Count(),
            customerOrders.Sum(o => o.Total)))
    .OrderByDescending(r => r.Revenue)
    .ThenBy(r => r.Name)
    .ToList();

foreach (CustomerReport r in report)
    Console.WriteLine($"{r.Name,-18} {r.City,-11} {r.Orders}  {r.Revenue,7:N2}");
Margaret Hamilton  Boston      2   198.70
Grace Hopper       New York    2   184.30
Ada Lovelace       London      2   172.70
Alan Turing        Manchester  1    54.00
Linus Torvalds     Helsinki    0     0.00

Had we used Join, Linus Torvalds wouldn't be on the list, because an inner join only brings records that have a match on both sides. GroupJoin instead produces an order group for every customer, even if it's empty. This is the reporting counterpart of the left join pattern we built with GroupJoin and DefaultIfEmpty in the join post. On an empty group Count() returns 0, and so does Sum.

Let's write the report back to JSON to hand it to another system. The first two customers are enough:

var writeOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true };

await using (FileStream output = File.Create("customer-report.json"))
    await JsonSerializer.SerializeAsync(output, report.Take(2), writeOptions);

Console.WriteLine(File.ReadAllText("customer-report.json"));
[
  {
    "name": "Margaret Hamilton",
    "city": "Boston",
    "orders": 2,
    "revenue": 198.70
  },
  {
    "name": "Grace Hopper",
    "city": "New York",
    "orders": 2,
    "revenue": 184.30
  }
]

The same JsonSerializerDefaults.Web preset turned property names into camelCase when writing. The decimal values were written as numbers, trailing zeros included. report.Take(2) is an IEnumerable; the serializer enumerated it into a JSON array, so we didn't need to call ToList() ourselves.

Large Files: Reading as a Stream

DeserializeAsync<List<Order>> loads every order into memory. That's fine for ten records, but not for an export of a few gigabytes. JsonSerializer.DeserializeAsyncEnumerable, which came with .NET 6, reads the outermost JSON array element by element and hands you each order as soon as it's read:

using System.Globalization;
using System.Text.Json;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);

int count = 0;
decimal largest = 0;

await using FileStream stream = File.OpenRead("orders.json");
await foreach (Order? order in JsonSerializer.DeserializeAsyncEnumerable<Order>(stream, options))
{
    if (order is null)
        continue;

    count++;
    largest = Math.Max(largest, order.Total);
}

Console.WriteLine($"Streamed {count} orders, largest: {largest}");

public record OrderItem(string Isbn, string Title, int Quantity, decimal UnitPrice);

public record Order(int Id, int CustomerId, DateTime OrderedAt, List<OrderItem> Items)
{
    public decimal Total => Items.Sum(i => i.Quantity * i.UnitPrice);
}
Streamed 10 orders, largest: 122.80

This time we counted all records without validation; the point is to show that only one order sits in memory at a time. Since the return type is IAsyncEnumerable<Order?>, we iterate with await foreach. What if you want to write LINQ on top of it?

using System.Text.Json;

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);

await using FileStream stream = File.OpenRead("orders.json");
IAsyncEnumerable<Order?> orders = JsonSerializer.DeserializeAsyncEnumerable<Order>(stream, options);

int bigOrders = orders.Where(o => o!.Total > 100m).Count();

public record OrderItem(string Isbn, string Title, int Quantity, decimal UnitPrice);

public record Order(int Id, int CustomerId, DateTime OrderedAt, List<OrderItem> Items)
{
    public decimal Total => Items.Sum(i => i.Quantity * i.UnitPrice);
}
Program.cs(8,24): error CS0411: The type arguments for method 'ImmutableArrayExtensions.Where<T>(ImmutableArray<T>, Func<T, bool>)' cannot be inferred from the usage. Try specifying the type arguments explicitly.

The error message is misleading: the compiler talks about ImmutableArrayExtensions.Where, which also lives in the System.Linq namespace. The real reason is that .NET 6 has no LINQ operators for IAsyncEnumerable<T>. Either you write your own loop inside await foreach, or you add the community package System.Linq.Async. .NET 10 brought these operators into the BCL; in the series' .NET 10 post we'll see that, along with the name clash it causes with the package.

Common Pitfalls

Computing the month in UTC. The dates in the JSON end with Z, meaning UTC. Order 1003 was placed on August 31 in UTC, but for a store in Istanbul it falls on September 1:

using System.Text.Json;

DateTime orderedAt = JsonSerializer.Deserialize<DateTime>("\"2026-08-31T22:30:00Z\"");
TimeZoneInfo istanbul = TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul");
DateTime local = TimeZoneInfo.ConvertTimeFromUtc(orderedAt, istanbul);

Console.WriteLine($"Kind: {orderedAt.Kind}");
Console.WriteLine($"UTC month: {orderedAt:yyyy-MM}, Istanbul month: {local:yyyy-MM}");
Kind: Utc
UTC month: 2026-08, Istanbul month: 2026-09

Our monthly report put this order in August. Which one is right is a business decision; what matters is making it consciously. If you want the report in a store's local calendar, convert the date to that time zone before grouping. Since .NET 6, IANA time zone names like Europe/Istanbul are also recognized on Windows versions with ICU support.

Producing reports from unvalidated data. Had we skipped the validation step, the duplicated order 1008 would count twice in October's revenue, and the negative-quantity line would silently reduce revenue. However correct your LINQ query is, the result is only as correct as its input.

Running the reading query over and over. Had we not materialized valid, every report would rerun the validation chain, meaning the HashSet lookups and the DistinctBy, from scratch. The multiple enumeration cost we measured in the previous post shows up on real data exactly like this.

A Small Experiment

★☆☆ Write a revenue report per city: match customers with orders, group by city, sort by revenue descending. How many orders did New York place?

★★☆ Produce the monthly report again in Istanbul time. Which month's revenue changes, and by how much?

★★★ Collect the validation result in a record (ValidationReport: the Ids of duplicated orders, orders with an unknown customer and orders with a bad quantity) and write it as validation.json next to customer-report.json. Then try doing the same validation in a single pass with DeserializeAsyncEnumerable, without collecting the orders in memory. Which rule can't be done in a single pass?

With this post, the core posts of the LINQ series built on C# 10 and .NET 6 are complete. The next three are version posts. In the first we look at .NET 7 and .NET 8: Order(), which needs no key selector, ToDictionary() without selectors, and what these two releases brought to LINQ performance. Under each heading we'll first see how we wrote it in .NET 6, then what the new version changes.