← Applied algorithms
Matching and making choices From two sets of rankings to a stable match

Gale–Shapley stable matching

Propose down the list. Hold the best offer.

The National Resident Matching Program places new doctors in residency programs with a “matching algorithm,” and explains it step by step. An applicant is tried at their first choice; if that program doesn’t rank them, “an attempt is then made to place Applicant A into the second choice program, and so on, until Applicant A obtains a tentative match.” A program that ranks a newcomer higher takes them, and the applicant it held is “bumped.” Nothing is settled until “all tentative matches become final and binding for training.”

The procedure it describes is deferred acceptance, the method David Gale and Lloyd Shapley published in 1962; the NRMP’s version adds modifications of its own. We’ll use it for a mentorship program: four learners, four mentors, everyone ranking the other side, and a match no pair would walk away from.

TypeScriptGoOne mentorship round in each language

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.

Gale–Shapley

Propose down the list. Hold the best offer.

MENTORSHIP ROUND · LEARNERS PROPOSE Proposal 1 of 8 · 1 offer held
Learners · proposeMentorsAAmaraBBenCChloeDDevPPriyaQQuinnRRosaSSamheld

Amara asks Rosa, who holds Amara’s offer.

held offer latest proposal · letters name the people

01/ 03
First choices

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:

Propose

The next name on your list

Never someone you have already asked. A mentor who didn’t rank you says no.

Hold

Keep the best offer so far

A free mentor holds any offer from someone on their list. A held offer isn’t final.

Let go

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.

TypeScriptReading
matching.ts
// 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
	};
}
GoAlongside
matching.go
// 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", &current})
				line = append(line, current)
				break asking
			default:
				proposals = append(proposals, Proposal{from, to, "rejected", &current})
			}
		}
	}

	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.

Five proposals in, Rosa has let Amara go to hold Dev’s offer. Amara ranks Rosa, Quinn, Sam, Priya. Quinn is holding Chloe and ranks Dev, Ben, Amara, Chloe. What happens next?

05 / Follow the cost

At most one proposal per name on a list.

Gale–Shapley: time and extra space, with P proposers, R receivers, L names on all the lists, and n the longest list
OperationTimeExtra spaceWhat it assumes
Build the receivers’ lookupsO(L)O(L)One map per receiver from name to place on their list, over all L names on the lists.
Make one proposalO(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 matchO(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 pairsO(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 sidesO(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 matchinggrows 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

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

Copy the complete example, let the mentors propose with Sam away, and predict before you run it: who is left without a mentor, and how many proposals does it take?

Back to applied algorithms →