LINQ in Practice · Lesson 28

Ranking within independent groups

Reset a positional rank for each partition and make tie ordering deterministic.

How it works

A leaderboard often needs a separate rank within each team rather than one position across the whole dataset. GroupBy creates the partitions. The inner ordering arranges each team's scores, and the indexed Select inside that group starts again from zero. Adding one produces the displayed one-based positions.

The outer OrderBy controls team presentation order, so blue appears before red. The inner OrderByDescending puts higher points first. ThenBy uses the name as an explicit tie breaker. Each part of the pipeline controls a different level of order, which is easier to verify than relying on the source's incidental arrangement.

This ranking assigns a unique position even when points tie. It is not dense ranking or competition ranking, where tied scores may share a rank and later ranks follow different rules. A business requirement must choose that policy before implementation. Calling every numbered report a rank can conceal an important semantic difference.

The projection occurs after sorting within each group. Adding the index before sorting would label the original source positions, not the final leaderboard positions. This is the same general rule for staged queries: an index describes the sequence at the stage where it is introduced.

Practice by giving Ada and Ben equal points, then reversing their source order. Verify that the name tie breaker keeps the report deterministic. Add a third team with one member and explain why its displayed rank is one regardless of how many records appeared in earlier teams.

Finally, write a dense-ranking variant in which equal point totals share a rank. Keep that implementation separate and compare the two expected outputs for tied input. Tests should cover multiple teams, ties, a single-member group, and an empty source. The report's correctness depends on partitioning, ordering, and the chosen ranking definition together.

A working example

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using System.Runtime.CompilerServices;

var scores = new[] { new Score("red", "Ada", 8), new Score("red", "Ben", 10), new Score("blue", "Cy", 9), new Score("blue", "Di", 7) };
var ranked = scores.GroupBy(score => score.Team)
    .OrderBy(group => group.Key, StringComparer.Ordinal)
    .SelectMany(group => group.OrderByDescending(score => score.Points)
        .ThenBy(score => score.Name, StringComparer.Ordinal)
        .Select((score, index) => $"{group.Key}:{score.Name}:{index + 1}"));
foreach (var line in ranked) Console.WriteLine(line);
record Score(string Team, string Name, int Points);

Expected output

blue:Cy:1
blue:Di:2
red:Ben:1
red:Ada:2
Try this
Apply indexed Select inside each group after ordering its members.