01 / The idea
Pair everyone so no two people would rather swap.
Amara, Ben, Chloe, and Dev each rank four mentors, Priya, Quinn, Rosa, and Sam, and each mentor ranks the four learners. The program needs one mentor per learner. The pairing can’t please everyone: Ben and Dev both put Sam first.
What it can avoid is a pair who would both rather be together than with the partners they got. If Dev and Rosa each preferred the other, they would arrange it themselves, and the published match would mean nothing. Such a pair is a blocking pair, and a matching with none is stable.
Gale–Shapley finds a stable matching: learners propose down their lists, and each mentor holds the best offer so far, letting it go only for a better one. Watch the eight proposals.
Propose down the list. Hold the best offer.
Amara asks Rosa, who holds Amara’s offer.
held offer latest proposal · letters name the people
Ask your first choice.
Amara asks Rosa, Ben asks Sam, and Chloe asks Quinn. Each mentor holds the only offer they have. A held offer isn’t a match yet.
Reduced motion: choose a scene to see its completed state.
Read this scene
Amara asks Rosa, Ben asks Sam, and Chloe asks Quinn. Each mentor holds the only offer they have. A held offer isn’t a match yet.
After 1 of 8 proposals: Rosa holds Amara. Latest: Amara asks Rosa, who holds Amara’s offer.
Watch and Step through replay the learners’ eight proposals. Try it runs the same TypeScript on the lists you reorder, with either side proposing, and starts fresh each time you open it.
Nothing was settled early. Rosa held Amara, then traded up to Dev, and Amara, let go, took Quinn from Chloe. The held offers only became the match when nobody was left to ask.
02 / Name the rule
Propose, hold the best, let go for better.
Every learner starts in a line, in the order they are listed. The first learner in line asks names from their list until someone holds their offer:
The next name on your list
Never someone you have already asked. A mentor who didn’t rank you says no.
Keep the best offer so far
A free mentor holds any offer from someone on their list. A held offer isn’t final.
Trade up, or turn down
A better offer replaces the held one, and whoever was let go joins the back of the line.
It always ends. A learner never asks the same mentor twice, so four learners and four mentors make at most 16 proposals. With complete lists on both sides it is never more than n² − n + 1, which is 13 here; the tests check that bound on hundreds of generated rounds. This round took 8.
It always ends stable. Take any learner and a mentor they rank above the partner they got: Dev ranks Sam above Rosa. Dev asked Sam first, and Sam turned Dev down for someone Sam ranked higher, Ben. A mentor’s held offer only ever gets better, so Sam still ends with someone Sam prefers to Dev. That pair can’t block, and the same argument works for every pair.
The Nobel Prize press release for 2012, which went to Shapley and Alvin Roth, defines stability the same way: “two agents cannot be found who would prefer each other over their current counterparts.”
Does the order in the line matter?The story changes; the match doesn’t
List the learners in another order and the proposals happen in another order, with different offers held along the way. The final pairs are the same. Both languages check it on hundreds of generated rounds by shuffling the proposers and comparing the pairs.
The reason is stronger than the order: with learners proposing, every learner ends with the best mentor they could have in any stable matching, and that best mentor doesn’t depend on who asked first. The tests try every matching on small rounds to confirm it.
03 / Read the shape
A line, a list pointer, and a held offer.
Basic form is the whole algorithm: checkSides and match, which returns the pairs, whoever is left unmatched, and every proposal
with how it ended. In the wild is what a program runs before publishing: blockingPairs, standings, totalRank, and compareSides. At the call site runs the round both ways. Both languages print the same four lines.
The whole algorithm: checkSides refuses bad names and rankings, and match walks a line of proposers down their lists while each receiver holds its best offer so far, recording every proposal and how it ended.
// Gale–Shapley stable matching, also called deferred acceptance. One side proposes down its ranked
// list; the other side holds the best offer it has had so far and trades up when a better one
// arrives. When nobody has anyone left to ask, the held offers are the matching, and no two people
// would both rather be with each other than with the partners they got.
export const MAX_PEOPLE = 32;
// A person's ranking, best first. Anyone left off the list is someone they won't be matched with.
export type Person = { name: string; ranks: string[] };
export type Outcome = 'held' | 'replaced' | 'rejected' | 'unacceptable';
// `other` is the proposer the receiver let go (replaced) or kept (rejected); otherwise null.
export type Proposal = { from: string; to: string; outcome: Outcome; other: string | null };
export type Pair = { proposer: string; receiver: string };
export type Matching = {
pairs: Pair[];
unmatchedProposers: string[];
unmatchedReceivers: string[];
proposals: Proposal[];
};
export type MatchingErrorCode = 'too-many' | 'bad-name' | 'bad-ranking' | 'bad-pair';
export class MatchingError extends Error {
readonly code: MatchingErrorCode;
constructor(code: MatchingErrorCode, message: string) {
super(message);
this.name = 'MatchingError';
this.code = code;
}
}
export function checkSides(proposers: Person[], receivers: Person[]): void {
if (proposers.length > MAX_PEOPLE || receivers.length > MAX_PEOPLE)
throw new MatchingError('too-many', `up to ${MAX_PEOPLE} people on each side`);
const names = new Set<string>();
for (const person of [...proposers, ...receivers]) {
if (!/^[a-z][a-z0-9-]{0,31}$/.test(person.name) || names.has(person.name))
throw new MatchingError('bad-name', `names are unique lowercase slugs: ${person.name}`);
names.add(person.name);
}
for (const [side, other] of [
[proposers, receivers],
[receivers, proposers]
]) {
const allowed = new Set(other.map((person) => person.name));
for (const person of side)
if (
new Set(person.ranks).size !== person.ranks.length ||
person.ranks.some((name) => !allowed.has(name))
)
throw new MatchingError(
'bad-ranking',
`${person.name} can rank each person on the other side once`
);
}
}
export function match(proposers: Person[], receivers: Person[]): Matching {
checkSides(proposers, receivers);
// Each receiver's ranking as a lookup, so comparing two offers is one step.
const place = new Map(
receivers.map((r) => [r.name, new Map(r.ranks.map((name, i) => [name, i]))])
);
const listOf = new Map(proposers.map((p) => [p.name, p.ranks]));
const next = new Map(proposers.map((p) => [p.name, 0])); // where each proposer is on their list
const held = new Map<string, string>(); // receiver → proposer whose offer it holds
const proposals: Proposal[] = [];
// Proposers without a held offer, in listed order. Someone let go joins the back of the line.
const line = proposers.map((p) => p.name);
for (let head = 0; head < line.length; head++) {
const from = line[head];
const list = listOf.get(from)!;
// Ask down the list until someone holds the offer or the list runs out.
while (next.get(from)! < list.length) {
const to = list[next.get(from)!];
next.set(from, next.get(from)! + 1);
const ranks = place.get(to)!;
const current = held.get(to) ?? null;
if (!ranks.has(from)) {
proposals.push({ from, to, outcome: 'unacceptable', other: null });
} else if (current === null) {
held.set(to, from);
proposals.push({ from, to, outcome: 'held', other: null });
break;
} else if (ranks.get(from)! < ranks.get(current)!) {
held.set(to, from);
proposals.push({ from, to, outcome: 'replaced', other: current });
line.push(current);
break;
} else {
proposals.push({ from, to, outcome: 'rejected', other: current });
}
}
}
const partner = new Map([...held].map(([receiver, proposer]) => [proposer, receiver]));
return {
pairs: proposers
.filter((p) => partner.has(p.name))
.map((p) => ({ proposer: p.name, receiver: partner.get(p.name)! })),
unmatchedProposers: proposers.filter((p) => !partner.has(p.name)).map((p) => p.name),
unmatchedReceivers: receivers.filter((r) => !held.has(r.name)).map((r) => r.name),
proposals
};
} // Gale–Shapley stable matching, also called deferred acceptance. One side proposes down its ranked
// list; the other side holds the best offer it has had so far and trades up when a better one
// arrives. When nobody has anyone left to ask, the held offers are the matching, and no two people
// would both rather be with each other than with the partners they got.
const MaxPeople = 32
// Person is a ranking, best first. Anyone left off the list is someone they won't be matched with.
type Person struct {
Name string
Ranks []string
}
// Proposal records one offer. Other is the proposer the receiver let go (replaced) or kept
// (rejected); otherwise nil.
type Proposal struct {
From, To, Outcome string
Other *string
}
type Pair struct{ Proposer, Receiver string }
type Matching struct {
Pairs []Pair
UnmatchedProposers []string
UnmatchedReceivers []string
Proposals []Proposal
}
type MatchingError struct{ Code, Message string }
func (e *MatchingError) Error() string { return e.Message }
var slug = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`)
func CheckSides(proposers, receivers []Person) error {
if len(proposers) > MaxPeople || len(receivers) > MaxPeople {
return &MatchingError{"too-many", fmt.Sprintf("up to %d people on each side", MaxPeople)}
}
names := map[string]bool{}
for _, person := range slices.Concat(proposers, receivers) {
if !slug.MatchString(person.Name) || names[person.Name] {
return &MatchingError{"bad-name", "names are unique lowercase slugs: " + person.Name}
}
names[person.Name] = true
}
for _, sides := range [][2][]Person{{proposers, receivers}, {receivers, proposers}} {
allowed := map[string]bool{}
for _, other := range sides[1] {
allowed[other.Name] = true
}
for _, person := range sides[0] {
seen := map[string]bool{}
for _, name := range person.Ranks {
if seen[name] || !allowed[name] {
return &MatchingError{"bad-ranking", person.Name + " can rank each person on the other side once"}
}
seen[name] = true
}
}
}
return nil
}
func Match(proposers, receivers []Person) (Matching, error) {
if err := CheckSides(proposers, receivers); err != nil {
return Matching{}, err
}
// Each receiver's ranking as a lookup, so comparing two offers is one step.
place := map[string]map[string]int{}
for _, r := range receivers {
place[r.Name] = map[string]int{}
for i, name := range r.Ranks {
place[r.Name][name] = i
}
}
lists := map[string][]string{}
// Proposers without a held offer, in listed order. Someone let go joins the back of the line.
line := make([]string, 0, len(proposers))
for _, p := range proposers {
lists[p.Name] = p.Ranks
line = append(line, p.Name)
}
next := map[string]int{} // where each proposer is on their list
held := map[string]string{} // receiver → proposer whose offer it holds
proposals := []Proposal{}
for head := 0; head < len(line); head++ {
from := line[head]
list := lists[from]
// Ask down the list until someone holds the offer or the list runs out.
asking:
for next[from] < len(list) {
to := list[next[from]]
next[from]++
ranks := place[to]
current, holding := held[to]
fromPlace, acceptable := ranks[from]
switch {
case !acceptable:
proposals = append(proposals, Proposal{from, to, "unacceptable", nil})
case !holding:
held[to] = from
proposals = append(proposals, Proposal{from, to, "held", nil})
break asking
case fromPlace < ranks[current]:
held[to] = from
proposals = append(proposals, Proposal{from, to, "replaced", ¤t})
line = append(line, current)
break asking
default:
proposals = append(proposals, Proposal{from, to, "rejected", ¤t})
}
}
}
partner := map[string]string{}
for receiver, proposer := range held {
partner[proposer] = receiver
}
m := Matching{[]Pair{}, []string{}, []string{}, proposals}
for _, p := range proposers {
if r, ok := partner[p.Name]; ok {
m.Pairs = append(m.Pairs, Pair{p.Name, r})
} else {
m.UnmatchedProposers = append(m.UnmatchedProposers, p.Name)
}
}
for _, r := range receivers {
if _, ok := held[r.Name]; !ok {
m.UnmatchedReceivers = append(m.UnmatchedReceivers, r.Name)
}
}
return m, nil
} Reading the TypeScriptMaps, and a line read with an index
Each receiver’s list becomes a Map from name to place, so comparing a new
offer with the held one is two lookups instead of two searches. The line of proposers is
a plain array read with an index, head: someone let go is pushed onto the
end, so nothing is ever removed from the front.
The inner while keeps one proposer asking until an offer is held or their
list runs out; break hands the turn to the next in line.
Reading the GoA labeled break, and empty strings for nobody
The same line and maps. break asking leaves the inner loop from inside the switch, where a plain break would only leave the switch. Proposal.Other is a *string, nil unless someone was let go or
kept, so it decodes from the shared cases’ null.
Where the TypeScript returns null for nobody, RankOf, Standing, and Choice use 0 and "", which is also what a JSON null decodes to.
What is refusedNames, rankings, and pairs
More than 32 people on a side is too-many. Names must be lowercase slugs,
unique across both sides (bad-name). A ranking may name each person on the
other side once, and only them (bad-ranking); leaving someone off is
allowed and means “not them.” blockingPairs and totalRank also refuse a pair that names someone missing, puts a person in two pairs, has the sides the
wrong way round, or joins two people who didn’t rank each other (bad-pair):
such a pair would both rather have nobody.
Both languages run every shared case, and on hundreds of generated rounds check that the result has no blocking pair, that nobody asks the same person twice, that the order of the line doesn’t change the pairs, and that the result is the best stable matching for every proposer and the worst for every receiver.
04 / Try a decision
Amara was let go. What happens next?
Decide before the feedback tells you.
05 / Follow the cost
At most one proposal per name on a list.
| Operation | Time | Extra space | What it assumes |
|---|---|---|---|
| Build the receivers’ lookups | O(L) | O(L) | One map per receiver from name to place on their list, over all L names on the lists. |
| Make one proposal | O(1) | O(1) | Two lookups to compare the new offer with the held one, and at most one name added to the line. |
| Run the whole match | O(P + L) | O(P + R + L) | Each proposer asks each name on their list at most once: never more than P × R proposals. |
| Check for blocking pairs | O(P × R × n) | O(1) | Every pair, each finding partners and ranks by scanning lists of up to n names. A partner map brings it to O(P × R). |
| Compare both sides | O(L + P × min(P, R)) | O(P + R + L) | Two matches, one with each side proposing, then a partner search for each of the P people through up to min(P, R) pairs. A partner map brings it to O(P + R + L). |
| Try every matching | grows like n! | O(n) | What the tests do to find every stable matching on small rounds. Not something to ship. |
The match itself is cheap: every proposal crosses one name off a proposer’s list for good, so the work is bounded by the lists. With complete lists, n people on each side make at most n² − n + 1 proposals, so the round here could take 13 and took 8. Even a program with 32 people on each side makes at most 993.
Checking a matching for blocking pairs looks at every learner–mentor pair, so it grows with P × R. That is fine for a check before publishing. Finding every stable matching by trying all matchings is not: the tests do it only on rounds of five or fewer.
06 / Give it a real job
Publish a match you can defend.
Before the program emails anyone, it runs blockingPairs on the result. It should
find none, and it doesn’t. The check is cheap insurance for the day someone edits a pairing by
hand.
Then it decides who proposes, because the answer changes the match. With learners proposing, Amara gets Quinn, her second choice, and Ben gets Sam, his first. With mentors proposing, in 5 proposals, Amara gets Sam, her third, and Ben gets Quinn, his second, while Quinn and Sam each do one place better. Chloe and Dev keep the same mentors either way. Both results are stable, and they are the only stable matchings of the 24 possible ones. Both score 15 when you add up every person’s rank for their partner, so a total can’t settle it: it is a choice about whose preferences come first.
Some rounds leave someone out. When Sam is away, three mentors can take three learners, and Amara is left without a mentor whichever side proposes. That isn’t luck: the tests check on generated rounds that the same people are matched in every stable matching, so the program can tell Amara early, whatever it decides about proposing.
The last temptation is a tidier-looking match. Pair Amara with Rosa, Ben with Sam, Chloe
with Priya, and Dev with Quinn, and the rank total drops to 14, the lowest of all 24
matchings. But Dev ranks Rosa second and Quinn third, and Rosa ranks Dev first and Amara
second. blockingPairs reports exactly that pair, and they would arrange it themselves.
This runs on the program’s server once every list is in, because no single visitor’s page holds everyone’s rankings; nothing in a component computes it.
07 / Make the call
Who proposes, and who could game it.
The 2012 press release says it directly: “Shapley was able to show how the specific design of a method may systematically benefit one or the other side of the market.” The NRMP calls its own algorithm “applicant-proposing,” the side the lesson’s learners are on.
The press release also says these methods “limit agents’ motives for manipulating the matching process.” Limit, not remove. No learner can get a mentor they rank higher by reordering their own list; the tests try every reordering on generated rounds in both languages. A mentor sometimes can. If Quinn swaps Amara and Chloe at the bottom of the list, the learners’ proposals take 10 steps and Quinn ends with Ben, Quinn’s real second choice, instead of Amara, the third. Judged by everyone’s real lists, that result is the mentors’ stable matching.
The same press release says redesigned systems for matching doctors with hospitals and students with schools “are all based on the Gale-Shapley algorithm, along with modifications that take into account specific circumstances and ethical restrictions.” This lesson covers the unmodified one-to-one version.
What it doesn’t do: it needs strict rankings, so ties must be broken before it runs. It pairs one person with one person; a program with several positions, as NRMP programs have, needs the many-to-one version, which this lesson doesn’t cover. It doesn’t make anyone the lowest total or the happiest on average, and it never pairs people who didn’t rank each other.
SourcesPages and records checked 14 September 2026
- National Resident Matching Program, How It Works: The Matching Algorithm.
- Nobel Prize Outreach, press release for the 2012 Prize in Economic Sciences, awarded to Alvin E. Roth and Lloyd S. Shapley “for the theory of stable allocations and the practice of market design.”
- D. Gale and L. S. Shapley, “College Admissions and the Stability of Marriage”, The American Mathematical Monthly 69(1), 9–15, 1962. Only the bibliographic record was checked, not the paper.
08 / Take the idea with you
Explain it without saying “Gale–Shapley.”
“One side asks down their list. The other side holds the best offer so far and swaps it only for a better one. When nobody is left to ask, the held offers are final, and no two people would both rather be together. The side that asks gets the best result any stable match allows.”
Before moving on, think of a place where two groups choose each other: interview slots, project teams, reviewers and papers. Ask whether anyone could walk away from the assignment, and who the current process quietly favors.
Connections to follow nextRelated lessons
- Queue and deque holds the line of proposers waiting for a turn.
- Hash map is what turns a mentor’s list into a one-step comparison.
- Dijkstra’s shortest path also keeps a tentative answer and improves it until it can’t be beaten.