Saving Christmas with PowerShell: Building a Reusable Matching Algorithm

Saving Christmas with PowerShell: Building a Reusable Matching Algorithm

What starts as a simple Secret Santa script quickly turns into a full-blown constraint-based matching engine.

In this video, we’ll walk you through the entire creation process. Starting with a simple shuffle, building constraints, adding backtracking, and ending with a polished, reusable matching function you can drop into any project.

This pattern applies far beyond the holidays: load balancing, on-call rotations, resource assignments, task distribution, or any time you need to match two sets under rules.

This post is a companion for the video embedded below. Scroll down to see the code from the video.

Starting Simple: The Naïve Matching Approach

Before we get into backtracking, constraint filtering, and building a reusable matching engine, it helps to start with the simplest possible version of a Secret Santa matcher. This initial approach isn’t production-ready. In fact, it barely qualifies as “ready.” But it gives us a clear foundation to build upon.

$participants = Import-Csv -Path '.\participants.csv'

# Set max number of tries

$MaxAttempts = 1000
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
# Random permutation of receivers
$receivers = $Participants | Get-Random -Count $Participants.Count

$pairs = @()
$valid = $true

# Loop through each Participants assigning and checking.
for ($i = 0; $i -lt $Participants.Count; $i++) {
    $giver = $Participants[$i]
    $receiver = $receivers[$i]

    # No self assignment
    if ($giver.Name -eq $receiver.Name) {
        $valid = $false
        break
    }

    $pairs += [pscustomobject]@{
        Giver    = $giver.Name
        Receiver = $receiver.Name
    }
}

# If everything is valid, set assignments and break from the loop
if ($valid) {
    $assignments = $pairs
    Write-Host "Valid assignment found on attempt $attempt."
    break
}
}
$assignments

What This Code Does (and Why It’s Not Enough)

This first version is essentially the algorithmic equivalent of drawing names out of a hat and hoping for the best. It works surprisingly well for small groups but falls apart as soon as you introduce real-world constraints like avoiding family members or preventing repeats from previous years.

Loading Participants

We begin by loading participants from a CSV file. This gives us a list of objects representing everyone in the exchange; names, families, emails, or any other fields you want to track.

$participants = Import-Csv -Path '.\participants.csv'

At this stage, we’re not enforcing any rules. It’s pure, unfiltered holiday optimism.

Trying Random Shuffles

Next, we set a limit on how many times we’re willing to try generating a valid pairing. Without this, we could end up in an infinite loop. Fun for no one except maybe chaotic neutral programmers.

$MaxAttempts = 1000

Each attempt creates a fully randomized order of receivers. This gives us a one-to-one mapping: giver at index i → receiver at index i.

$receivers = $Participants | Get-Random -Count $Participants.Count

Checking for Self-Assignments

Now we check whether anyone drew themselves. Something most Secret Santa coordinators agree is frowned upon unless you’re really into self-care.

if ($giver.Name -eq $receiver.Name) {
$valid = $false
break
}

If we detect a self-assignment, we mark this attempt as invalid and break out of the loop. Then we try again with a fresh shuffle.

Building the Pair List

For every valid pairing, we build a lightweight object that captures the giver and receiver relationship.

$pairs += [pscustomobject]@{
Giver    = $giver.Name
Receiver = $receiver.Name
}

At the end of a fully valid pass, $pairs contains the complete assignment.

Stopping Once We Find a Valid Matching

When an attempt produces no self-assignments, we accept it as valid and stop the loop. This avoids wasting time on additional shuffles after we already have a workable solution.

if ($valid) {
$assignments = $pairs
Write-Host "Valid assignment found on attempt $attempt."
break
}

Why This Approach Falls Short

This naive method is a helpful starting point, but it quickly runs into limitations. It doesn’t prevent pairing spouses, siblings, roommates, or arch-nemeses. It doesn’t consider last year’s assignments. And it relies entirely on random chance, which is about as reliable as a Christmas tree held together with wishful thinking.

In the next section, we’ll begin evolving this into a far smarter, constraint-aware algorithm that can handle real-world rules and eventually become a reusable matching function for all your automation needs.

Building the Full Matching Engine

Now that we’ve explored the naive approach, it’s time to level up. This next version of the script evolves our simple random shuffle into a complete, constraint-aware matching engine. It handles families, avoids repeating last year’s pairings, and ensures we always end up with a valid assignment, even when the constraints start to box us in.

To keep everything reusable and testable, we wrap the entire process in a function called Get-SecretSantaAssignments. This allows us to pass in different participant lists and history files, and makes the logic portable for other types of matching problems (task assignment, scheduling, rotations, and more).

function Get-SecretSantaAssignments {
[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [array]$Participants,

    [Parameter()]
    [array]$LastYearAssignments = @()
)

if ($Participants.Count -lt 2) {
    throw "You need at least two participants. Otherwise it's just buying yourself presents."
}

$Givers = $Participants | Select-Object -Property @{l = 'Name'; e = { "$($_.Name) $($_.Family)" } },
@{l = 'First'; e = { $_.Name } }, Family

# Build lookup for last year's pairs: "Giver|Receiver" -> $true
$lastYearPairs = @{}
foreach ($row in $LastYearAssignments) {
    if ($null -ne $row.Giver -and $null -ne $row.Receiver) {
        $key = "$($row.Giver)|$($row.Receiver)"
        $lastYearPairs[$key] = $true
    }
}

# Precompute candidate receivers for each giver
$candidateMap = @{}
foreach ($giver in $Givers) {
    $validReceivers = $Givers | Where-Object {
        # No self
        $_.Name -ne $giver.Name -and
        # No same family
        $_.Family -ne $giver.Family -and
        # No same pair as last year
        -not $lastYearPairs["$($giver.Name)|$($_.Name)"]
    }

    if ($validReceivers.Count -eq 0) {
        throw "Participant '$($giver.Name)' has no valid receivers. Constraints are impossible with the current setup."
    }

    # Randomize the candidates so we don't always get the same answer
    $candidateMap[$giver.Name] = @(
        $validReceivers | Get-Random -Count $validReceivers.Count
    )
}

# Order givers by number of candidates (fewest options first)
$orderedGivers = $Givers | Sort-Object {
    $candidateMap[$_.Name].Count
}

# Build a candidates array aligned with orderedGivers for simpler indexing
$candidatesByIndex = @()
foreach ($giver in $orderedGivers) {
    $candidatesByIndex += , $candidateMap[$giver.Name]
}

# Number of givers
$giverCount = $orderedGivers.Count
# which candidate to try next per depth
$nextCandidateIndex = @(0) * $giverCount
# receiverName -> $true
$usedReceivers = @{}
# chosen receiver object per depth
$receiverByDepth = @($null) * $giverCount
# Initial depth of zero to start
$depth = 0

while ($depth -ne $giverCount) {
    $giver = $orderedGivers[$depth]
    $candidates = $candidatesByIndex[$depth]
    $i = $nextCandidateIndex[$depth]

    if ($i -ge $candidates.Count) {
        # Exhausted all candidates at this depth -> backtrack
        Write-Verbose "Backtracking from depth $depth"
        $nextCandidateIndex[$depth] = 0
        if ($depth -eq 0) {
            throw "No valid complete assignment found. Constraints may be impossible for this data set."
        }
        # Undo previous assignment
        $depth--
        $prevReceiver = $receiverByDepth[$depth]
        if ($prevReceiver) {
            $usedReceivers.Remove($prevReceiver.Name) | Out-Null
            $receiverByDepth[$depth] = $null
        }
        continue
    }

    # Try candidate i at this depth, and advance the pointer for next time
    $receiver = $candidates[$i]
    $nextCandidateIndex[$depth] = $i + 1

    if (-not $usedReceivers.ContainsKey($receiver.Name)) {
        # Assign & go deeper
        $usedReceivers[$receiver.Name] = $true
        $receiverByDepth[$depth] = $receiver
        $depth++
    }
    else {
        # Receiver already used – loop again at same depth to try next candidate
        continue
    }
}

# Build final assignment objects
# receiverByDepth is aligned with orderedGivers; we want back to original giver objects
$assignmentByName = for ($i = 0; $i -lt $giverCount; $i++) {
    [PSCustomObject]@{
        Giver    = $orderedGivers[$i].Name
        Receiver = $receiverByDepth[$i].Name
    }
}
$assignmentByName
}

$participantsPath = '.\participants.csv'
$previousPath      = '.\SecretSanta_2024.csv'

$participants = Import-Csv -Path $participantsPath

$previous = if (Test-Path $previousPath) {
Import-Csv -Path $previousPath
}

$assignments = Get-SecretSantaAssignments -Participants $participants -LastYearAssignments $previous

$assignments | Format-Table -AutoSize

$assignments | Export-Csv -Path ".\SecretSanta_$(Get-Date -Format 'yyyy').csv" -NoTypeInformation -Encoding UTF8

Function Inputs and Basic Validation

The function accepts both a list of participants and an optional list of last year’s assignments. This gives us everything we need to enforce rules like “no one gets themselves,” “no one gets someone in their own family,” and “avoid duplicate matchups from previous years.”

We also check that the participant count is at least two, otherwise this becomes less Secret Santa and more “treat yourself.”

function Get-SecretSantaAssignments {
[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [array]$Participants,

    [Parameter()]
    [array]$LastYearAssignments = @()
)

if ($Participants.Count -lt 2) {
    throw "You need at least two participants. Otherwise it's just buying yourself presents."
}

Preparing the Giver Objects

Each participant is transformed into a structured object that stores their full display name, first name, and family grouping. This makes filtering cleaner later when we start excluding invalid receiver options.

$Givers = $Participants | Select-Object -Property @{l = 'Name'; e = { "$($_.Name) $($_.Family)" } },
@{l = 'First'; e = { $_.Name } }, Family

Loading Last Year’s History

If a CSV of last year’s assignments exists, we convert it into a lookup table. Each entry in the table uses the format Giver|Receiver for lightning-fast checks. This ensures we never repeat pairings from year to year.

# Build lookup for last year's pairs: "Giver|Receiver" -> $true
$lastYearPairs = @{}
foreach ($row in $LastYearAssignments) {
    if ($null -ne $row.Giver -and $null -ne $row.Receiver) {
        $key = "$($row.Giver)|$($row.Receiver)"
        $lastYearPairs[$key] = $true
    }
}

Precomputing Valid Receiver Candidates

For each giver, we create a list of all valid receivers. This filtering enforces three constraints:

• No giving to yourself
• No giving within your own family
• No repeating last year’s pairing

We also randomize each list of candidates to produce fresh pairings each year, even with identical inputs.

# Precompute candidate receivers for each giver
$candidateMap = @{}
foreach ($giver in $Givers) {
    $validReceivers = $Givers | Where-Object {
        # No self
        $_.Name -ne $giver.Name -and
        # No same family
        $_.Family -ne $giver.Family -and
        # No same pair as last year
        -not $lastYearPairs["$($giver.Name)|$($_.Name)"]
    }

    if ($validReceivers.Count -eq 0) {
        throw "Participant '$($giver.Name)' has no valid receivers. Constraints are impossible with the current setup."
    }

    # Randomize the candidates so we don't always get the same answer
    $candidateMap[$giver.Name] = @(
        $validReceivers | Get-Random -Count $validReceivers.Count
    )
}

Ordering Givers by Difficulty

Before solving the puzzle, we sort the list of givers by how many valid receiver choices they have, with those with fewer options earlier in the list. This simple heuristic dramatically reduces backtracking because we address the most constrained participants first.

# Order givers by number of candidates (fewest options first)
$orderedGivers = $Givers | Sort-Object {
    $candidateMap[$_.Name].Count
}

# Build a candidates array aligned with orderedGivers for simpler indexing
$candidatesByIndex = @()
foreach ($giver in $orderedGivers) {
    $candidatesByIndex += , $candidateMap[$giver.Name]
}

Backtracking to Guarantee a Valid Matching

At the heart of the function is a depth-based backtracking loop. The algorithm walks giver-by-giver, assigning the next available valid receiver. If it reaches a point where no candidates remain, it backtracks, undoing the previous assignment and trying the next receiver.

This ensures that even in highly constrained situations, the function will still find a valid solution as long as one exists. And if none exists, it throws a clear and expressive error instead of silently failing.

# Number of givers
$giverCount = $orderedGivers.Count
# which candidate to try next per depth
$nextCandidateIndex = @(0) * $giverCount
# receiverName -> $true
$usedReceivers = @{}
# chosen receiver object per depth
$receiverByDepth = @($null) * $giverCount
# Initial depth of zero to start
$depth = 0

while ($depth -ne $giverCount) {
    $giver = $orderedGivers[$depth]
    $candidates = $candidatesByIndex[$depth]
    $i = $nextCandidateIndex[$depth]

    if ($i -ge $candidates.Count) {
        # Exhausted all candidates at this depth -> backtrack
        Write-Verbose "Backtracking from depth $depth"
        $nextCandidateIndex[$depth] = 0
        if ($depth -eq 0) {
            throw "No valid complete assignment found. Constraints may be impossible for this data set."
        }
        # Undo previous assignment
        $depth--
        $prevReceiver = $receiverByDepth[$depth]
        if ($prevReceiver) {
            $usedReceivers.Remove($prevReceiver.Name) | Out-Null
            $receiverByDepth[$depth] = $null
        }
        continue
    }

    # Try candidate i at this depth, and advance the pointer for next time
    $receiver = $candidates[$i]
    $nextCandidateIndex[$depth] = $i + 1

    if (-not $usedReceivers.ContainsKey($receiver.Name)) {
        # Assign & go deeper
        $usedReceivers[$receiver.Name] = $true
        $receiverByDepth[$depth] = $receiver
        $depth++
    }
    else {
        # Receiver already used – loop again at same depth to try next candidate
        continue
    }
}

# Build final assignment objects
# receiverByDepth is aligned with orderedGivers; we want back to original giver objects
$assignmentByName = for ($i = 0; $i -lt $giverCount; $i++) {
    [PSCustomObject]@{
        Giver    = $orderedGivers[$i].Name
        Receiver = $receiverByDepth[$i].Name
    }
}
$assignmentByName
}

Building and Exporting the Final Assignments

Once all givers have valid receivers, we compile the results into structured objects and return them from the function. The caller then formats them for display and exports the assignments into a new CSV for future reference.

$participantsPath = '.\participants.csv'
$previousPath      = '.\SecretSanta_2024.csv'

$participants = Import-Csv -Path $participantsPath

$previous = if (Test-Path $previousPath) {
Import-Csv -Path $previousPath
}

$assignments = Get-SecretSantaAssignments -Participants $participants -LastYearAssignments $previous

$assignments | Format-Table -AutoSize

$assignments | Export-Csv -Path ".\SecretSanta_$(Get-Date -Format 'yyyy').csv" -NoTypeInformation -Encoding UTF8

A Fully Reusable Matching Function

The beauty of this function is that it’s not tied exclusively to Secret Santa. Because the constraints and assignment logic are abstract, you can reuse this engine for scheduling, task assignments, workload balancing, and any scenario where people need to be paired under rules.

With this in place, we’ve moved far beyond random shuffles into a robust algorithmic solution that can solve complex matching problems with reliability and elegance.