{"id":23465,"date":"2026-08-24T21:05:04","date_gmt":"2026-08-24T21:05:04","guid":{"rendered":"https:\/\/scannn.com\/building-a-semantic-layer-what-it-is-and-how-we-did-it-at-posthog\/"},"modified":"2026-08-24T21:05:04","modified_gmt":"2026-08-24T21:05:04","slug":"building-a-semantic-layer-what-it-is-and-how-we-did-it-at-posthog","status":"publish","type":"post","link":"https:\/\/scannn.com\/lv\/building-a-semantic-layer-what-it-is-and-how-we-did-it-at-posthog\/","title":{"rendered":"Building a semantic layer: What it is and how we did it at PostHog"},"content":{"rendered":"\n<div>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">Ask Claude, Cursor, and PostHog AI the same question: &#8220;what was our MRR last month?&#8221;, and you&#8217;ll get three different queries and three different numbers.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">I did exactly this, half-expecting the tools to agree. They didn&#8217;t; one summed a Stripe table, one found a slightly different Stripe table, and one tried to reconstruct recurring revenue from raw events and got the proration wrong. Every method and number was plausible, but there was no way to tell which was right.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">The problem is what &#8220;MRR&#8221; means at PostHog, which table holds it, and how it&#8217;s calculated all lives in people&#8217;s heads. So every agent session reinvents the definition from scratch, slightly differently.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">Humans have this problem too. Every new analyst needs to learns which revenue table is the real one and how Stripe is connected. Agents just amplify the issue by answering confidently and hallucinating to fill the gaps in their knowledge, and nobody thinks to double check it.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">You can&#8217;t fix it with a smarter model. You need to give every agent a single place to read the definition from so &#8220;MRR&#8221;, and every other metric you want to define, mean the same thing on every call. That place is the semantic layer, and this is the story of building it into PostHog&#8217;s context warehouse.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">What is a semantic layer?<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">A semantic layer is a dictionary of definitions that everyone (agent and human) reads from. You define MRR once, approve it once, and every query you run from then on returns the same number.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">The most important thing to understand about it is what it doesn&#8217;t do. It doesn&#8217;t copy your data, replace your warehouse, or move a single row of data. It sits on top of the data you already have and describes it, &#8220;this is what MRR means, this is the table to trust, this is how these two sources connect.&#8221; It&#8217;s a map, not a second copy of the territory. That&#8217;s why it&#8217;s called a layer.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">It fixes is three kinds of knowledge that only exists as tribal memory:<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">What our metrics are: MRR isn&#8217;t just &#8220;revenue&#8221;, it&#8217;s a specific calculation over a specific source.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">Which tables to trust (and which tables to avoid): A mature project imports dozens of sources and builds hundreds of data models. Plenty of them could answer &#8220;revenue.&#8221; Only one is current and blessed as accurate by the finance team. Others can be useful too, but there might be tables that we should avoid, e.g. they have been deprecated.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">How the data joins together: The Stripe customer ID maps to an organization property, but only after it&#8217;s reformatted, and nothing tells you that except the analyst who figured it out last time.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">If you&#8217;re a small company with a handful of tables you interact with regularly, you probably know where everything is. But, as you grow, you add more data sources and models, the meaning of those tables stop being obvious, least of all to an AI agent seeing your schema for the first time.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">Semantic layer \/s\u026a\u02c8m\u00e6nt\u026ak \u02c8le\u026a\u0259\/ \u2013 noun.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">A governed catalog of definitions that sits on top of your existing data and tells people and machines what it means: what each metric is, which tables to trust, and how sources connect. It describes the data; it doesn&#8217;t copy or move it.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">Glossary<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">&#8211; Catalog: the structured, queryable inventory the semantic layer maintains.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">&#8211; Metric: a named, governed definition of a business measure, like MRR.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">&#8211; Governed: nothing becomes official until a human approves it.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">Where this lives: The context warehouse<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">The context warehouse is where PostHog pulls together everything an agent needs to answer questions. That includes product events, imported sources like your Stripe data, and data models. The semantic layer makes sure agents know what your data means and can interpret it correctly.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">A catalog actually stores the semantic layer. What integrates it so fully is that it is just SQL. Every definition shows up as ordinary tables. Metrics are a table. There&#8217;s no bespoke &#8220;catalog API&#8221; for an agent to learn; if it can run execute-sql, it already knows how to read the entire semantic layer. Discovery is a query, not an integration.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">The reason three tools gave three MRR numbers is that each had to invent an answer. When you have a catalog, it becomes the first thing any agent checks: is there an approved metric? If so, it runs the governed definition instead of writing its own SQL. Same question, same number, every time.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">AI generated, human owned<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">Agents are genuinely good at proposing improvements to your data governance. Point one at your schema and it&#8217;ll happily draft metric definitions, suggest canonical tables, and spot likely joins from column names and sample data. Letting agents do the first pass is useful, but letting it edit doesn&#8217;t improve trust, it muddles it further.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">So everything an agent creates lands as proposed. Nothing an agent touches is ever canonical on its own. A human promotes it, approving a metric, certifying a table, accepting a join. We added two guardrails to verify any definition changes even after approval:<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">Editing an approved metric&#8217;s definition drops it straight back to proposed. Changing what a number means re-opens the question of whether it&#8217;s right.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">If a metric was built from an existing insight and someone edits that insight, the metric gets flagged as drifted. It&#8217;s a signal that the definition it was born from has moved and a human should re-review.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">These give agents one simple rule to live by: a result is canonical only when status = &#8216;approved&#8217; and is_drifted = false. Everything else including proposed, drifted, and archived, gets labeled non-canonical, and a well-trained agent will tell you so rather than passing it off as gospel.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">The design decisions we made (and the ones we didn&#8217;t)<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">The interesting part of building this was the tempting-looking architecture choices we walked away from. Almost every simplification you reach for breaks in a way that shows up later as a wrong number. Here are a few of the choices we didn&#8217;t make:<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">&#8220;Why not just make every metric a SQL query?&#8221;<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">This is the first thing everyone suggests: funnels and trends all compile down to SQL eventually, so why not require every metric to be SQL and be done with it? Because the same metric written by hand as SQL gives a different number than the insight it came from.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">Take activation, which at PostHog is a funnel. To make it SQL-only, someone must rewrite that funnel by hand. PostHog&#8217;s funnel engine does a lot under the hood: steps happen in order, per person, inside a conversion window, with exclusions and deduplication applied. A hand-written SQL version gets some of that wrong.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">Storing the metric as the same funnel definition the insight uses, and running it through the same engine, makes that mismatch impossible. The metric and the dashboard execute identical queries. That guarantee is why we support insight-shaped metrics, not just SQL.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">So where does that definition live? When you create a metric from an insight, PostHog snapshots the insight&#8217;s query and stores it on the metric server-side. That snapshot is what runs, which is how the metric and the insight stay in lockstep.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">It also means the metric can notice when it falls out of step. Because the snapshot is stored but the insight keeps evolving, PostHog compares the two every time you read the metric and flags it as drifted if someone has changed the insight underneath it. Nothing runs in the background, the check happens live.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">&#8220;Why not write definitions in plain English?&#8221;<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">A good part of it is. Users can define how the agent should calculate a given metric using a Markdown structure. However, plain English is limited because it is less deterministic than insight-backed and SQL-backed metrics.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">&#8220;Why not just point a metric at an existing insight?&#8221;<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">Insights already blend SQL, funnels, and trends. Pointing at one would keep the metric and the dashboard in lockstep. The problem is that insights are shared objects that people edit all day. &#8220;Editing the definition resets approval&#8221; is impossible to enforce on something everyone&#8217;s mutating.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">Instead of pointing at the insight, metric-create takes the insight, snapshots its query, and remembers where it came from. You get lockstep awareness with the drift flag, without handing governance to an object anyone can change on a whim.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">&#8220;Why not build a semantic query language, like dbt?&#8221;<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">That&#8217;s the &#8220;right&#8221; long-term answer for composing non-SQL metrics into arbitrary queries, and it&#8217;s exactly what we chose not to build in v1. It&#8217;s a whole new language for both humans and agents to learn, plus a compiler to maintain forever. Even dbt won&#8217;t let you plain-SELECT a metric in a random editor, you must go through their compiling API.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">Our metric-run endpoint is the same architecture minus the language. If usage data later shows people really need in-editor composition, there&#8217;s a path we can take to get there. Until then, it&#8217;s complexity we haven&#8217;t earned.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">SQL metrics are defined over a saved view. Create the view first, point the metric at it, and the metric stays composable with plain SQL. Views are already PostHog&#8217;s &#8220;queryable named SQL&#8221; primitive, so we reused them instead of inventing a parallel one.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">What&#8217;s next for semantic layering in PostHog<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">The PostHog semantic layer is in beta right now, you can work with it through MCP tools, PostHog&#8217;s UI, and SQL.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">We&#8217;re measuring success around this by checking:<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">Do agents get questions right? The bar we set is \u226580% on a fixed set of golden questions, clearly above a baseline with no catalog.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">When an approved metric exists, do agents actually use it instead of deriving their own number?<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">Does the catalog keep growing through everyday use, or does it spike during setup and then go silent?<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">As more of what we build becomes agent-driven, reliable data is the difference between an AI you can trust and one you can&#8217;t.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">The semantic layer is in beta. Get started with these instructions, or setup the PostHog MCP and ask your agent to recommend a new semantic layer catalog.<\/p>\n<p class=\"mb-4 leading-relaxed whitespace-pre-wrap\">Written by Lizzie Epton (Developer Marketer) and Thiago Rocha Salvatore (Product Engineer) from PostHog.<\/p>\n<\/div>\n<p><a href=\"https:\/\/x.com\/posthog\/status\/2090858894419693598?utm_source=tldrfounders\">Source link <\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Ask Claude, Cursor, and PostHog AI the same question: &#8220;what was our MRR last month?&#8221;, and you&#8217;ll get three different queries and three different numbers. I did exactly this, half-expecting the tools to agree. They didn&#8217;t; one summed a Stripe table, one found a slightly different Stripe table, and one tried to reconstruct recurring revenue [&hellip;]<\/p>\n","protected":false},"author":16,"featured_media":23466,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[143],"tags":[],"class_list":["post-23465","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-ai"],"_links":{"self":[{"href":"https:\/\/scannn.com\/lv\/wp-json\/wp\/v2\/posts\/23465","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/scannn.com\/lv\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/scannn.com\/lv\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/scannn.com\/lv\/wp-json\/wp\/v2\/users\/16"}],"replies":[{"embeddable":true,"href":"https:\/\/scannn.com\/lv\/wp-json\/wp\/v2\/comments?post=23465"}],"version-history":[{"count":0,"href":"https:\/\/scannn.com\/lv\/wp-json\/wp\/v2\/posts\/23465\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/scannn.com\/lv\/wp-json\/wp\/v2\/media\/23466"}],"wp:attachment":[{"href":"https:\/\/scannn.com\/lv\/wp-json\/wp\/v2\/media?parent=23465"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/scannn.com\/lv\/wp-json\/wp\/v2\/categories?post=23465"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/scannn.com\/lv\/wp-json\/wp\/v2\/tags?post=23465"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}