The day our dedupe hid El Clásico: matching one event across seven live feeds

2026-09-28 · 9 min read · Place to Be

Place to Be is a live radar of upcoming events: concerts, matches, festivals, club nights, street parties. It pulls listings from seven feeds (Ticketmaster, ESPN, Resident Advisor, OpenAgenda, Que faire à Paris, TheSportsDB and a public-holidays API), refreshes every two hours, and ranks around 49,000 upcoming events by the crowd each one will draw.

Almost all of the hard engineering is in one question: are these two listings the same event?

Get it wrong in one direction and a game shows up twice on a city's board. Get it wrong in the other direction and a real event is merged into something else and disappears. The second failure is worse, because nothing on the page looks broken. This post is about both, including the week our merge made the biggest club game of the autumn vanish.

Three ways one thing becomes many listings

Every feed has its own idea of what a "listing" is, and they disagree in three distinct ways.

1. One event sold as several products. Ticket sellers list the main event, then the VIP package, the premium seats and the parking as separate "events": Five next to Five | Venue Premium Packages. Same night, same venue, same headliner. These collapse on a key built from the headliner, the local day and the venue, and the cleanest title wins.

2. One game phrased differently by each feed. This is the interesting one:

ESPN          "Kansas City Chiefs at Las Vegas Raiders"     (Away at Home)
Ticketmaster  "Las Vegas Raiders vs. Kansas City Chiefs"    (Home vs. Away)
              "Nashville Predators v Minnesota Wild - T-Shirt Giveaway"
              "NFL London 2026: Houston Texans v Jacksonville Jaguars"
TheSportsDB   "Lyon vs Crystal Palace"                      (Home vs Away)

3. One show on many dates. A residency, an exhibition or a musical is listed once per performance, each with its own id. ABBA Voyage has hundreds of dates. Nobody wants hundreds of cards, so those fold into one card for the run.

Our first keys for (1) and (3) compared title words: the first four words, or the first three "headliner" tokens after stripping ticket vocabulary. That is fine for concerts. For games it is useless, because the two feeds put the sides in opposite order and use different joiners. When we measured it, 759 games were on the boards twice, and every home game of the Las Vegas Raiders took two of the city's top 30 slots.

A game's identity is structural, not textual

A game is not its title. It is two sides, one place, one time. So we stopped comparing strings and started parsing:

// The joiner between the two sides. Whitespace on both sides, so "Atlanta" or
// "Vasco" can never split a title.
const JOINER = /\s(?:at|vs\.?|v\.?|versus|@)\s/;
// Separators that front a prefix ("NFL London 2026:") or a promo tail ("- T-Shirt Giveaway").
const SEPARATOR = /\s*[|:•·–]\s*|\s+-\s+/;

function contestSides(title: string): [string [], string []] | null {
  const t = normalise(title);                 // lowercase, strip accents and brackets
  const m = JOINER.exec(t);
  if (!m) return null;                        // "Tours: Truist Park" is not a game
  const left = t.slice(0, m.index).split(SEPARATOR).pop() ?? "";
  const right = t.slice(m.index + m [0].length).split(SEPARATOR) [0] ?? "";
  // Keep distinctive words only: drop "fc", "women", "game", "night", years...
  return [distinctive(left), distinctive(right)];
}

Two listings are then the same game when:

  1. both parse as side JOINER side,
  2. each side of one shares a distinctive word with a different side of the other, in either order (home and away are reversed between feeds),
  3. they are at the same venue (same building key, or a shared distinctive venue word, so "UNIQLO Field at Dodger Stadium" meets "Dodger Stadium"),
  4. they start within three hours of each other, or, when a feed has no published kickoff yet, on the same local day.
function sameSides(x: Sides, y: Sides): boolean {
  return (overlaps(x [0], y [0]) && overlaps(x [1], y [1]))
      || (overlaps(x [0], y [1]) && overlaps(x [1], y [0]));
}

A few details turned out to matter more than the main idea:

  • Initials. ESPN writes "LAFC", Ticketmaster writes "Los Angeles Football Club". Each side also carries its initials as one extra token (~lafc), which is allowed to match a single word on the other side.
  • Prefixes. "Czech" and "Czechia" are the same team. A word matches another if one is a prefix of the other and at least four letters long.
  • A shared word names neither side. In "New York Rangers vs New York Islanders", "new" and "york" are on both sides, so they are dropped, unless that would leave a side empty ("Sydney FC v Western Sydney Wanderers": "sydney" is Sydney FC).
  • "At" can be a place. Ticketmaster sells the Las Vegas Grand Prix as 3-DAY GA - T-MOBILE ZONE AT SPHERE at the venue "T-Mobile Zone at Sphere". Read as a game, three different passes merged into one. If the words either side of "at" read as a phrase of the venue's own name, the title names a place, not an opponent.
  • The venue rule keeps multi-sport clubs apart. Real Madrid against Barcelona in football at the Bernabéu and in basketball at the WiZink Center could fall on the same day. They share sides and a date, but not a building.

Comparing every pair of 11,000 stored fixtures would be slow, so candidates meet through an index keyed on (local day, side word), and matches are joined with a small union-find. The whole pass runs in about a second.

The copy that survives must be the one you will see again

Merging two listings means deleting one. Which one?

Our first answer was "the most structured source". Wrong question. A merge only sees one refresh's batch. ESPN is re-fetched in full on every refresh, every league, the whole window. A Ticketmaster fixture far enough ahead is only re-fetched when its city comes round in the deep-sweep rotation, which can take most of a day. If the Ticketmaster copy survived, the very next refresh that did not include it would write the ESPN copy straight back, and the duplicate would reappear until the next deep sweep: the board flickered between one card and two.

So the survivor rule is a stability rule:

function refetchedInFull(source: string): number {
  return source === "espn" || source === "thesportsdb" ? 0 : 1;
}
// First key of every cross-source merge, then source priority, then a total tie-break
// (title, then source id) so two passes that read rows in different orders never
// pick different survivors and delete each other's.

The total tie-break matters too. The ingest merge and a second "reach-back" pass over what is already stored read rows in different orders. If a tie fell back on arrival order, each pass would keep a different copy and delete the other's, and the event's page address would change between refreshes.

The bug: how the women's Clásico swallowed the men's

Fold (3), one card per multi-date run, was keyed on the headliner, the venue building and the city, plus a "program key" so two different concerts at the same hall stay apart. It had been running since July.

Then we were writing a monthly newsletter from our data and checking every date against the official source before publishing. Our board said El Clásico, Real Madrid at Barcelona, was on October 4 at the Spotify Camp Nou. LALIGA says October 25.

Neither was wrong. ESPN lists both:

esp.w.1-401882497   "Real Madrid at Barcelona"   Liga F    Spotify Camp Nou   2026-10-04
esp.1-401882832     "Real Madrid at Barcelona"   LALIGA    Spotify Camp Nou   2026-10-25

The women's Clásico is at the Camp Nou three weeks before the men's, and ESPN names both games identically. Same title, same building, same city, two dates: to the series fold, that is a residency with two performances. It kept the soonest date and deleted the other. The men's Clásico was on no board at all.

Once you see it, you see it everywhere. When we replayed the rule over the stored catalogue, 595 sports cards had absorbed 827 other games:

  • repeat home games in the NBA: the Knicks host the Hawks in November and again in January, and the second game vanished into the first;
  • the Women's Super League north London derby at Tottenham Hotspur Stadium (October 24) absorbing the Premier League one there (December 5);
  • Manchester City at Arsenal: the men's game at the Emirates in November and the women's there in January, one card.

The fix is one idea: a scheduled game is never a date of a run. The same two sides at the same ground on another date is another game.

function isScheduledContest(r: Row): boolean {
  return r.category === "sports" && contestSides(r.title, r.venue_name) !== null;
}

export function seriesGroupKey(r: Row): string {
  if (isScheduledContest(r)) return `fixture:${r.source}:${r.source_id}`; // never groups
  return `${seriesKey(r.title, r.venue_name, r.city)}::${programKey(r.title)}`;
}

One game listed by several feeds is still merged, by the contest rule above, which checks the time. What stopped merging is two different games that happen to share a title. The replay also confirmed what should keep folding: 106 cards that really are runs (daily stadium tours, Monster Jam weekends, weekly lucha libre nights, tennis sessions) fold exactly as before.

There was a second, smaller bug hiding behind the first. Once the two Clásicos were separate, the Barcelona board showed two cards with the same title. ESPN names women's fixtures by the clubs, exactly like the men's. For the competitions whose clubs share the men's names (Liga F, the WSL, Première Ligue, the Vrouwen Eredivisie, the Women's Champions League and the Women's World Cup) the title now says (Women). The marker is not cosmetic: the contest rule reads it, so a women's and a men's game between the same clubs at the same ground a couple of hours apart can never merge either. The Barcelona board now shows both games.

What we would tell anyone merging live feeds

  1. Identity is structure, not string similarity. Find what actually makes two records the same thing in the real world (two sides, one place, one time) and parse for it. Fuzzy title matching fails in both directions at once.
  2. Measure what a merge deletes, not only what it merges. A duplicate is visible; a wrongly absorbed event is not. Every new rule gets replayed over the full stored catalogue first, and the report lists what would be removed, so a human can read the losers.
  3. Pick survivors for stability. When refreshes are partial, the survivor has to be the copy that every refresh brings back, with a total order behind it. Otherwise your merge undoes itself on the next run.
  4. Test with real pairs from production. Every case in the test files is a pair that actually occurred: the Raiders game three ways, the LAFC initials, "Zone at Sphere", the two Clásicos.
  5. Check your output against the source of truth. The Clásico bug had passed every test. It was found by checking one date in a newsletter draft against the league's own schedule.

If you want to see the result, the boards are live for thousands of cities, and the ranking method explains how a crowd score is built. Questions about the method are welcome at hello@placetobe.cc.

Disclosure: this article was drafted with AI assistance by the team behind Place to Be, from our own code, tests and production data.

The day our dedupe hid El Clásico: matching one event across seven live feeds | Place to Be