﻿<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>endjin.com</title><atom:link href="https://endjin.com/rss.xml" rel="self" type="application/rss+xml" />
    <link>https://endjin.com</link>
    <description>endjin is a UK-based Technology Consultancy specialising in Data, Analytics &amp; AI, and Cloud Native App Dev on Microsoft Fabric, Databricks &amp; Azure. We help small teams achieve big things.</description>
    <copyright>Endjin Limited</copyright>
    <docs>http://www.rssboard.org/rss-specification</docs>
    <generator>Vellum Static Site Generator</generator>
    <image>
      <link>https://endjin.com</link>
      <title>endjin.com</title>
      <url>https://res.cloudinary.com/endjin/image/upload/v1775912583/assets/images/logo/endjin-logo-square.png</url>
    </image>
    <language>en</language>
    <lastBuildDate>Tue, 05 May 2026 00:00:00 GMT</lastBuildDate>
    <pubDate>Tue, 05 May 2026 00:00:00 GMT</pubDate>
    <ttl>60</ttl>
    <item>
      <title>SQLBits 2026: A Conference Recap</title>
      <description>SQLBits is one of the largest data platform conferences in Europe. Here's a recap of my experience at SQLBits 2026, held at the ICC Wales.</description>
      <link>https://endjin.com/blog/sqlbits-2026-a-conference-recap</link>
      <guid isPermaLink="true">https://endjin.com/blog/sqlbits-2026-a-conference-recap</guid>
      <pubDate>Tue, 05 May 2026 00:00:00 GMT</pubDate>
      <category>Microsoft Fabric</category>
      <category>SQLBits</category>
      <category>Data</category>
      <category>Power BI</category>
      <category>DAX</category>
      <category>OneLake</category>
      <category>SQL Server</category>
      <category>Data Factory</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/sqlbits-2026-a-conference-recap.png" />
      <dc:creator>Carmel Eve</dc:creator>
      <content:encoded><![CDATA[<p>I recently attended <a href="https://sqlbits.com/">SQLBits 2026</a> at the ICC Wales - one of the largest data platform conferences in Europe. In the run-up to the conference, I was part of the SQLBits <a href="https://sqlbits.com/about/inclusivity/">DEI (Diversity, Equity and Inclusion) panel</a>, where we worked on various initiatives to make the event more inclusive - from the "One SQLBits, Many Nations" map in the community hub, to non-sensory food options, all-day snacks, and the "Find Your People" networking sessions. It was brilliant to see a lot of this come together on the day (and especially lovely to see so many kids and parents at the <a href="https://codeclub.org/">Code Club</a> sessions on the Saturday!).</p>
<p>Across four days, I went to sessions on everything from the SQL Server roadmap and DAX deep dives, to ingesting SharePoint data into Fabric, home automation, inclusive team building, and avoiding burnout. It was a packed few days, and I came away with a lot of new knowledge and plenty to think about.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/sqlbits-poster.jpeg" alt="SQLBits Poster" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/05/sqlbits-poster.jpeg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/05/sqlbits-poster.jpeg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/05/sqlbits-poster.jpeg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/05/sqlbits-poster.jpeg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>This post gives an overview of the sessions I attended. I'll also be writing some deeper technical posts on the topics that I found most interesting!</p>
<h2 id="day-1">Day 1</h2>
<h3 id="keynote-the-sql-roadmap">Keynote - The SQL Roadmap</h3>
<p><em>Priya Sathy, Shiva Gurumurthy, Bob Ward, Anna Hoffman</em></p>
<p>The conference kicked off with a Keynote focused on the SQL Server roadmap. There were a lot of announcements about migrating to Azure SQL Managed Instance, including cost estimation tools, the ability to check the effect of changes via lineage, and the fact that you can now manage memory and performance independently. They also touched on VMs - updating to the new series can provide huge throughput increases for analytics, even with smaller VMs.</p>
<p>Anna Hoffman did a couple of great demos. The first used GitHub Copilot to analyse why a stored procedure was running slowly. The second showcased the notebook experience in the mssql VS Code extension, where you can create reports and export them for sharing.</p>
<p>There was also a section on SQL Database in Fabric, highlighting how it's natively integrated into OneLake, autonomous, secure, and optimised for AI. Anna demonstrated using the mssql extension in VS Code whilst connected to Fabric to make changes to a database, and then showing how those changes were reflected in the Fabric UI and could be committed to Git. It's clear that the developer experience for SQL in Fabric is getting a lot of attention.</p>
<p>Finally, there was a section on monitoring in SQL and database agents (all still in preview). This included estate-level triage, asking Copilot for insights, and agents putting alerts straight into Teams and tagging relevant people - a promising step towards more proactive database management.</p>
<h3 id="variable-libraries-in-fabric">Variable Libraries in Fabric</h3>
<p><em>Kevin Arnold</em></p>
<p>Next up was a session on Microsoft Fabric variable libraries - originally scheduled as a 20-minute slot, but they let him continue into the break and he ended up speaking for around 45 minutes (which was a good thing, as there was plenty to cover!).</p>
<p>Variable libraries are the mechanism intended for managing environment-specific configuration in Fabric. The session covered what they are, how they work across different Fabric item types, and some best practices for using them safely.</p>
<p>Watch out for a new post on best practices when using variable libraries!</p>
<h3 id="microsoft-fabric-and-the-mess-of-sharepoint">Microsoft Fabric and the Mess of SharePoint</h3>
<p><em>Laura Graham-Brown</em></p>
<p>Let's be honest - there's no escaping SharePoint. This session from Laura Graham-Brown explored the various options for ingesting SharePoint data into Microsoft Fabric. She was a great presenter and covered a lot of ground, walking through the practical trade-offs of each approach.</p>
<p>Again, keep an eye out for an upcoming post which will go through this in more detail.</p>
<h3 id="conversational-analytics-at-lloyds-banking-group">Conversational Analytics at Lloyd's Banking Group</h3>
<p><em>Andrew Herman, Sean Hughes</em></p>
<p>This was an interesting session about Lloyd's Banking Group's journey from traditional reporting to "GenBI" (Generative BI). The scale involved was impressive - over 100,000 reports, 3 million interactions per month, 27,000 workspaces, and 65,000 active users.</p>
<p>As part of their journey, they did a PoC to prove whether switching from traditional reports to conversational AI would help with the classic challenges of slow insights, inefficiency, and data silos.</p>
<p>What I found most interesting was less the AI angle itself, and more the broader transformation approach they described: proving value early, measuring impact quantitatively, winning senior sponsorship, building capability across the organisation, establishing a single source of truth for key datasets, and baking in CI/CD and governance from the start.</p>
<p>These are solid data transformation principles regardless of whether AI is involved - though the AI angle certainly helps with getting that initial buy-in and excitement!</p>
<h3 id="fabric-admin-panel">Fabric Admin Panel</h3>
<p>I attended a Fabric admin panel session hoping for discussion around workspace management and change management best practices. It ended up being more of a Q&amp;A driven by the audience, which was mostly made up of Fabric administrators.</p>
<p>A few interesting things came up:</p>
<ul>
<li>The Fabric team are considering workspace-level tags (high/medium/low priority), where jobs can then be prioritised accordingly.</li>
<li>They also recommended building your own Fabric capacity metrics app - which, reading between the lines, suggests the built-in monitoring story still has room for improvement.</li>
<li>They heavily implied that per-operation metrics are coming, which would be a welcome addition!</li>
</ul>
<p>There was also an interesting discussion about governance versus usability. The general advice was to lean towards openness and invest in education, with the reasoning being that overly restrictive policies tend to backfire - users will find creative workarounds. (The example given was users screenshotting Power BI reports and extracting data from the screenshots after exports were disabled!)</p>
<h3 id="shine-bright-like-a-star-without-burning-out">Shine Bright Like a Star, Without Burning Out!</h3>
<p><em>Gloria Georgieva Clare</em></p>
<p>As I always try to at conferences, I made sure to attend at least one less technical talk. Gloria Georgieva Clare gave an open and personable session on avoiding burnout, and I'm really glad I went.</p>
<p>She talked about the concept of having four "hobs" - health, social, work, and hobbies - and how you can't have all four firing at full power at any one time. She recommended a few books: <em>The Burnout Society</em>, <em>Fair Play</em> (about unseen, unpaid domestic labour), and <em>Solve Your Stress Cycle</em>.</p>
<p>One of the things that resonated with me most was the idea of defining personal "drivers and guardrails" - concrete commitments like "I want to meet a friend after work once a week" or "I shouldn't be eating lunch at my desk". She also made the important point that just because a stressful situation has ended, doesn't mean the effects of it instantly go away.</p>
<p>I think reflecting on these things semi-regularly is really valuable, and a conference is a good prompt for doing it.</p>
<h2 id="day-2-optimising-dax-queries-full-day-workshop">Day 2 - Optimising DAX Queries (Full-Day Workshop)</h2>
<p><em>(<a href="https://www.sqlbi.com/author/alberto-ferrari/">Alberto Ferrari</a> - <a href="https://www.sqlbi.com/">SQLBI</a>)</em></p>
<p>Day 2 I attended a full-day training workshop with <a href="https://www.sqlbi.com/author/alberto-ferrari/">Alberto Ferrari</a> on optimising DAX queries. I learnt a huge amount about how the underlying engines that power Power BI work, and how the in-memory database (VertiPaq) is optimised. We went through real examples of slow queries and why they were slow - often for reasons I would never have known about beforehand.</p>
<p>The workshop covered everything from how VertiPaq stores data using column-oriented storage and various encoding techniques, through to how the formula engine and storage engine work together when executing DAX queries, and practical techniques for identifying and fixing performance bottlenecks.</p>
<p>Watch out for an upcoming series, which will run through this in much more detail.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/power-bi-sqlbits.jpeg" alt="Power BI logo printed on coffee foam" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/05/power-bi-sqlbits.jpeg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/05/power-bi-sqlbits.jpeg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/05/power-bi-sqlbits.jpeg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/05/power-bi-sqlbits.jpeg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<h2 id="day-3">Day 3</h2>
<p>Day 3 was a bit more relaxed. One of the nice things about SQLBits is that the conference social events (in this case, a pub quiz the night before) are actually a good way to meet people. I spent the morning chatting to various people I'd met, which is probably as valuable as any session.</p>
<h3 id="what-an-automaton-nerd-learnt-by-automating-their-home">What an Automaton Nerd Learnt by Automating Their Home</h3>
<p><em>Rob Sewell</em></p>
<p>This was a fun one. Rob Sewell talked about how he'd automated his house using <a href="https://www.home-assistant.io/">Home Assistant</a> - an open-source home automation platform. He had a network of hundreds of devices all connected via a local network, which was pretty impressive.</p>
<p>What I liked most about this talk was his point that even when you're doing something for fun at home, proper engineering practices and requirements gathering still matter. He had some great examples of what happens when you skip them - including a security setup designed to alert them if someone was in the garden after dark (via a foghorn noise and all the lights turning on), which worked perfectly until they realised they'd forgotten to account for the cat.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/rob-sewell-sqlbits.jpeg" alt="Rob Sewell presenting his home networking diagram" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/05/rob-sewell-sqlbits.jpeg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/05/rob-sewell-sqlbits.jpeg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/05/rob-sewell-sqlbits.jpeg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/05/rob-sewell-sqlbits.jpeg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<h3 id="people-first-building-strong-inclusive-data-teams-that-actually-deliver">People first: building strong, inclusive data teams that actually deliver</h3>
<p><em>Hollie Whittles</em></p>
<p>This was a 20-minute session, and while it was fairly high-level, there were some points that stuck with me.</p>
<p>Hollie Whittles gave some statistics around how communication is almost always what causes projects to fail, and talked about steps they'd taken to improve it within their team - including anonymous feedback mechanisms and surveys to better understand working styles.</p>
<p>There was a section on neurodiversity that I found particularly interesting. An estimated 30% of people in tech are neurodiverse, but only 16% say there's ever been a conversation about it in their workplace. She highlighted some common workplace practices that can unintentionally disadvantage neurodiverse team members:</p>
<p>Meetings without agendas - people who need more prep time are disadvantaged before the meeting has even begun. Only giving feedback in group settings - many people find public criticism destabilising and need privacy to process constructively. "Open door" policies - which only work for people who are already comfortable stepping through the door.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/invisible-friction.jpeg" alt="Slide: examples of workplace practices that can disadvantage neurodiverse colleagues" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/05/invisible-friction.jpeg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/05/invisible-friction.jpeg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/05/invisible-friction.jpeg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/05/invisible-friction.jpeg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>She also talked about recognising how different team members respond when pressure rises - some withdraw and go quiet, some escalate everything to urgent, and some people-please and agree to everything until they burn out. Being able to recognise these patterns in your team is a useful skill.</p>
<h3 id="get-creative-with-power-bi-making-core-visuals-shine">Get Creative with Power BI - Making Core Visuals Shine</h3>
<p><em>Valerie Junk</em></p>
<p>This session was about visualisation and report design in Power BI. A lot of it was fairly straightforward, but there were a few practical tips I picked up.</p>
<p>On tables specifically: use data bars (but not for every column), only highlight what's really important rather than colour-coding everything (just the top and bottom 3, perhaps - I'm definitely guilty of over-colouring), and grey out less important information. Though it's worth noting that heavy conditional formatting can be terrible for accessibility, so having an option to toggle it off is a good idea.</p>
<p>On that note, she showed a neat technique for toggling conditional formatting on and off using a button slicer, without resorting to bookmarks (which, in my experience, can be a bit of a nightmare). The approach is:</p>
<ol>
<li>Create a table with two values: "Formatting" and "No Formatting"</li>
<li>Add a button slicer connected to that table</li>
<li>Update your conditional formatting rules to check <code>SELECTEDVALUE</code> of that column</li>
</ol>
<p>Simple, but effective - and avoids the fragility that comes with bookmark-based approaches.</p>
<h2 id="day-4">Day 4</h2>
<p>Day 4 was the final day, and featured two talks from my colleague <a href="https://endjin.com/who-we-are/our-people/barry-smart/">Barry Smart</a>.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/barry-sqlbits.jpeg" alt="Barry Smart presenting at SQLBits" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/05/barry-sqlbits.jpeg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/05/barry-sqlbits.jpeg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/05/barry-sqlbits.jpeg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/05/barry-sqlbits.jpeg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<h3 id="spark-unplugged-how-in-process-analytics-is-making-distributed-computing-an-expensive-investment">Spark Unplugged: How In-Process Analytics Is Making Distributed Computing An Expensive Investment</h3>
<p>(<a href="https://endjin.com/who-we-are/our-people/barry-smart/">Barry Smart</a> - endjin)</p>
<p>Barry's first talk challenged the assumption that distributed computing (Spark, in particular) is always the right tool for analytical workloads. With in-process analytics engines like DuckDB and Polars becoming increasingly capable, there's a growing argument that for many workloads, you can get comparable (or better) performance without the overhead and cost of a distributed compute cluster. If you're interested in this topic, keep an eye out for a follow-up post!</p>
<h3 id="no-compromise-data-apps-why-streamlit-is-the-missing-piece-in-your-analytics-stack">No-Compromise Data Apps: Why Streamlit is the Missing Piece in Your Analytics Stack</h3>
<p>(<a href="https://endjin.com/who-we-are/our-people/barry-smart/">Barry Smart</a> - endjin)</p>
<p>Barry's second talk focused on <a href="https://streamlit.io/">Streamlit</a> - a Python framework for building interactive data applications. The session made the case that Streamlit fills an important gap in the analytics stack: the space between a Jupyter notebook (great for exploration, not great for sharing) and a full web application (powerful, but heavy to build). If you're a data team looking to get interactive tools into the hands of business users without a full frontend development effort, it's well worth a look!</p>
<h2 id="overall">Overall</h2>
<p>SQLBits is a well-run conference with a good mix of deep technical content, practical sessions, and softer topics around wellbeing, inclusion, and career development. I came away with a lot of things to think about and try out - which is about all you can ask from a conference, really.</p>
<p>If you're working in the data space - whether that's SQL Server, Fabric, Power BI, or anything in between - it's well worth considering. The 2026 Cartoon theme was a nice touch too!
<img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/05/me-sqlbits.jpeg" alt="Carmel Eve with her SQLBits lanyard" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/05/me-sqlbits.jpeg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/05/me-sqlbits.jpeg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/05/me-sqlbits.jpeg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/05/me-sqlbits.jpeg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>]]></content:encoded>
    </item>
    <item>
      <title>Multi-layer Caching with the Decorator Pattern</title>
      <description>Databricks SQL cold starts kill web API performance. Fix it with two-layer caching: Azure Blob Storage &amp; IMemoryCache, using the Decorator pattern.</description>
      <link>https://endjin.com/blog/multi-layer-caching-with-the-decorator-pattern</link>
      <guid isPermaLink="true">https://endjin.com/blog/multi-layer-caching-with-the-decorator-pattern</guid>
      <pubDate>Fri, 01 May 2026 05:30:00 GMT</pubDate>
      <category>dotnet</category>
      <category>Data Engineering</category>
      <category>Databricks</category>
      <category>SQL Serverless</category>
      <category>Application Development</category>
      <category>Caching</category>
      <category>Azure</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/multi-layer-caching-with-the-decorator-pattern.png" />
      <dc:creator>Jonathan George</dc:creator>
      <content:encoded><![CDATA[<p>TL;DR; Querying a Databricks SQL Serverless endpoint for analytical data is fast once the cluster is warm, but cold-start latency and query execution times make it unsuitable as the direct backing store for a web API. We solved this with two layers of caching: Azure Blob Storage for persistence across restarts, and <code>IMemoryCache</code> for sub-millisecond in-process reads, implemented cleanly using the Decorator pattern.</p>
<h2 id="the-performance-challenge">The Performance Challenge</h2>
<p>As part of a recent project, we were building an analytical web API that serves sales data: products, retailers, historical figures and projections, to a React front-end. The data is produced by an ETL process that runs in Databricks and writes the results to Delta tables in a data lake. The natural way to query that data is through Databricks SQL Serverless: it handles the complex analytical workloads, scales well, and integrates cleanly with the rest of the stack.</p>
<p>There's a catch, though. Databricks SQL Serverless clusters can be paused when idle, and cold-start latency can add several seconds—sometimes tens of seconds to the first request after a period of inactivity. Even on a warm cluster, query execution time for some of the larger datasets runs into multiple seconds. For a web API that needs to feel responsive, that's a problem.</p>
<p>The good news is that most of the data retrieved from Databricks is <em>reference data</em>: it changes infrequently, and when it does change, it changes in a controlled, versioned way. That observation is what makes aggressive caching safe, and it's the insight that shaped the approach described here.</p>
<p><strong>Key observation:</strong> the application works with specific named versions of the sales data. Within a given version, the data is completely immutable. There's no risk of serving stale data from a cache, because the data simply doesn't change once a version is published.</p>
<h2 id="understanding-the-data-access-requirements">Understanding the Data Access Requirements</h2>
<p>All data access in the application flows through a single interface, <code>ISalesDataRepository</code>. Here's a trimmed version of its key methods:</p>
<pre><code class="language-csharp">public interface ISalesDataRepository
{
    Task&lt;string&gt; GetLatestVersionIdAsync();
    Task&lt;VersionInfo&gt; GetVersionAsync(string versionId);
    Task&lt;Product[]&gt; GetProductsAsync(string versionId);
    Task&lt;Retailer[]&gt; GetRetailersAsync(string versionId);
    Task&lt;SalesSummary[]&gt; GetSalesAsync(string versionId, DateRange dateRange);
    Task&lt;SalesSummary[]&gt; GetSalesByIdsAsync(string versionId, string[] ids);
}
</code></pre>
<p>The versioning model is central to everything. A version is created by the ETL process and is immutable once published. The application operates within a specific version, most requests include a <code>versionId</code> that scopes the data being retrieved.</p>
<p>Not all data falls into the same category, though. When we analyse the methods, three distinct types emerge:</p>
<ul>
<li><strong>Fully immutable within a version</strong> — products, retailers, and other reference data. Once fetched for a given version, these can be cached indefinitely. They will never change.</li>
<li><strong>Near-real-time</strong> — the latest version ID. This needs a short time-to-live (we use five minutes) to pick up new versions when they're published without hammering the source on every request.</li>
<li><strong>On-demand lookups</strong> — targeted queries like <code>GetSalesByIdsAsync</code>, where the combination of parameters is effectively unbounded. Caching these isn't meaningful; they always go direct to Databricks.</li>
</ul>
<p>That analysis drives a <em>selective</em> caching strategy rather than a blanket one. Not everything is worth caching, and not everything can be cached with the same TTL.</p>
<h2 id="the-decorator-pattern-a-primer">The Decorator Pattern: A Primer</h2>
<p>If you haven't used the Decorator pattern before, the idea is straightforward. A decorator implements the same interface as the class it wraps. It adds behaviour: before, after, or instead of, delegating calls to the inner implementation. The consumer doesn't know, or care, whether it's talking to a "real" implementation or a decorator. It just uses <code>ISalesDataRepository</code>.</p>
<p>This is a natural fit for caching. The actual data access code stays clean and focused on talking to Databricks. The caching logic lives entirely in the decorators. The two concerns don't touch each other.</p>
<p>In our case, the chain looks like this:</p>
<pre><code>Client
  └─► MemoryCachingSalesDataRepository             (Layer 2: in-process, sub-millisecond)
        └─► BlobStorageCachingSalesDataRepository  (Layer 1: shared, persistent, fast)
              └─► SalesDataRepository              (Real implementation: Databricks SQL)
</code></pre>
<p>Each layer wraps the one below it. The <code>MemoryCachingSalesDataRepository</code> doesn't know it's wrapping a blob cache, it just knows it has an <code>ISalesDataRepository</code> to delegate to when it misses. Dependency injection wires the chain together; the decorators themselves have no knowledge of each other.</p>
<h2 id="layer-1-azure-blob-storage-cache">Layer 1: Azure Blob Storage Cache</h2>
<h3 id="why-blob-storage">Why Blob Storage?</h3>
<p>An in-process memory cache is lost when the API restarts or when a new instance spins up. Our application runs in Azure Container Apps, which scales out to multiple replicas and restarts during deployments. Without a persistent cache layer, every new instance would need to hit Databricks on its first request, exactly the cold-start problem we're trying to avoid.</p>
<p>Blob Storage is cheap, fast for reads, and shared across all replicas. It's not as fast as in-process memory, but it's orders of magnitude faster than waiting for a Databricks cluster to warm up.</p>
<h3 id="how-it-works">How It Works</h3>
<p><code>BlobStorageCachingSalesDataRepository</code> implements <code>ISalesDataRepository</code> and wraps the real <code>SalesDataRepository</code>. For each cacheable method, it constructs a deterministic blob path based on the data type, version ID, and any relevant parameters, for example, <code>sales/{versionId}/products.bin</code> for the products list.</p>
<p>The core of the implementation is a generic <code>GetOrCreateAsync&lt;T&gt;()</code> helper:</p>
<pre><code class="language-csharp">private async Task&lt;T&gt; GetOrCreateAsync&lt;T&gt;(
    string blobPath,
    Func&lt;Task&lt;T&gt;&gt; factory)
{
    var blobClient = _containerClient.GetBlobClient(blobPath);

    if (await blobClient.ExistsAsync())
    {
        var content = await blobClient.DownloadContentAsync();
        return MemoryPackSerializer.Deserialize&lt;T&gt;(content.Value.Content.ToArray());
    }

    // Cache miss: fetch from inner repository
    var result = await factory();

    // Write to blob storage for next time
    var bytes = MemoryPackSerializer.Serialize(result);
    await blobClient.UploadAsync(BinaryData.FromBytes(bytes), overwrite: true);

    return result;
}
</code></pre>
<p>The data is stored as binary blobs using <a href="https://github.com/Cysharp/MemoryPack">MemoryPack</a>, a high-performance binary serialiser for .NET. Compared to JSON, this keeps blob sizes small and deserialisation fast, both matter at the scale of multiple API instances reading from shared storage.</p>
<h3 id="preventing-the-thundering-herd">Preventing the Thundering Herd</h3>
<p>A naïve implementation has a race condition that's particularly nasty during cold starts. If the blob doesn't exist and multiple requests arrive concurrently, which is exactly what happens when a new container instance starts up and the front-end fires several API calls at once, they all miss the cache and all hit Databricks simultaneously.</p>
<p>This is the <a href="https://en.wikipedia.org/wiki/Thundering_herd_problem">thundering herd problem</a>. In the worst case, you end up making dozens of parallel queries to a cluster that's still warming up.</p>
<p>The solution is a <code>ConcurrentDictionary&lt;string, SemaphoreSlim&gt;</code> keyed by blob path. When a cache miss occurs, we acquire the semaphore for that blob path before proceeding. Inside the lock, we check the blob again, another request may have already populated it while we were waiting. If it's still a miss, we fetch from the inner repository and write the result. Here's the pattern in full:</p>
<pre><code class="language-csharp">private async Task&lt;T&gt; GetOrCreateAsync&lt;T&gt;(
    string blobPath,
    Func&lt;Task&lt;T&gt;&gt; factory)
{
    var blobClient = _containerClient.GetBlobClient(blobPath);

    // Fast path: blob already exists
    if (await blobClient.ExistsAsync())
    {
        var content = await blobClient.DownloadContentAsync();
        return MemoryPackSerializer.Deserialize&lt;T&gt;(content.Value.Content.ToArray());
    }

    // Slow path: acquire per-blob semaphore to prevent thundering herd
    var semaphore = _semaphores.GetOrAdd(blobPath, _ =&gt; new SemaphoreSlim(1, 1));
    await semaphore.WaitAsync();

    try
    {
        // Double-check: another waiter may have populated the blob
        if (await blobClient.ExistsAsync())
        {
            var content = await blobClient.DownloadContentAsync();
            return MemoryPackSerializer.Deserialize&lt;T&gt;(content.Value.Content.ToArray());
        }

        var result = await factory();

        var bytes = MemoryPackSerializer.Serialize(result);
        await blobClient.UploadAsync(BinaryData.FromBytes(bytes), overwrite: true);

        return result;
    }
    finally
    {
        semaphore.Release();
    }
}
</code></pre>
<p>Only one request per unique blob path reaches Databricks. All others wait for the semaphore, benefit from the result, and return immediately. It's worth noting that this applies <em>per instance</em>, if there are multiple container replicas running, each will independently populate its own copy of the blob. In practice that's fine: the first request to any instance pays the Databricks cost; subsequent requests benefit from the cached blob. If it does become a problem, there are more complex solutions using shared locks, for example, <a href="https://github.com/corvus-dotnet/Corvus.Leasing">Corvus.Leasing</a> which uses Azure blob storage to provide a means to acquire, release and extend exclusive leases to mediate resource access in distributed processing.</p>
<h3 id="whats-deliberately-not-cached-here">What's Deliberately Not Cached Here</h3>
<p>Version lookups, <code>GetLatestVersionIdAsync</code> and <code>GetAvailableVersionsAsync</code> always go to the source. We want the application to notice when a new version is published within a reasonable time. Caching these in blob storage would give us no meaningful benefit over the in-memory TTL we apply at the layer above.</p>
<p>Targeted on-demand lookups by ID also bypass the blob cache. The combination space, different sets of IDs against different versions, is too large to cache meaningfully.</p>
<h3 id="graceful-degradation">Graceful Degradation</h3>
<p>All blob I/O is wrapped in try/catch. If the cache layer fails for any reason—transient connectivity, permissions, a bad serialisation, it logs a warning and falls through to the inner repository. The application keeps working; it's just slower until the cache is warm again.</p>
<h2 id="layer-2-in-memory-cache">Layer 2: In-Memory Cache</h2>
<h3 id="why-a-second-layer">Why a Second Layer?</h3>
<p>Even a fast Blob Storage read involves a network round-trip and deserialisation overhead. For a busy API serving the same reference data many times per second, that adds up. <code>IMemoryCache</code> keeps deserialised objects in the process's heap. Reads are effectively instantaneous, no network, no deserialisation, just a dictionary lookup.</p>
<h3 id="how-it-works-1">How It Works</h3>
<p><code>MemoryCachingSalesDataRepository</code> wraps the Blob Storage decorator (which in turn wraps the real repository). It uses the standard <code>cache.GetOrCreateAsync()</code> pattern, with expiry configured per data type:</p>
<ul>
<li><strong>Immutable data within a version</strong>: no expiry, held in memory until the process restarts.</li>
<li><strong>Latest version ID</strong>: five-minute sliding expiry, so new versions are picked up in a timely manner.</li>
</ul>
<p>Cache keys incorporate the version ID where relevant, so different versions don't collide.</p>
<p>Here's a representative method:</p>
<pre><code class="language-csharp">public async Task&lt;Product[]&gt; GetProductsAsync(string versionId)
{
    using var activity = _activitySource.StartActivity("GetProducts");

    var cacheKey = $"{ProductsCacheKeyPrefix}{versionId}";

    if (_cache.TryGetValue(cacheKey, out Product[]? cached))
    {
        activity?.SetTag("cache.hit", true);
        return cached!;
    }

    activity?.SetTag("cache.hit", false);

    var result = await _innerRepository.GetProductsAsync(versionId);
    _cache.Set(cacheKey, result);

    return result;
}
</code></pre>
<h3 id="observability-with-activity-source">Observability with Activity Source</h3>
<p>Each method creates an <code>Activity</code> via <code>ActivitySource</code>, which participates in distributed tracing through OpenTelemetry. We record whether the request was a cache hit or miss as a tag on the activity: <code>cache.hit = true/false</code>.</p>
<p>This turns out to be genuinely useful. When we look at the observability dashboard, we can see at a glance what proportion of requests are being served from memory versus falling through to lower layers. It's how we validated that the cache was actually working as expected after deployment.</p>
<h3 id="what-doesnt-get-cached-in-memory">What Doesn't Get Cached In Memory</h3>
<p>Targeted on-demand lookups by ID, as with the blob layer, always delegate to the inner repository. The combination space makes in-memory caching impractical, we'd risk holding enormous amounts of data with a very low hit rate.</p>
<h2 id="wiring-it-together-with-dependency-injection">Wiring It Together with Dependency Injection</h2>
<p>The decorator chain is composed in the service registration. The order matters: each decorator needs to wrap the layer below it, not the one above. We register the concrete types first, then register the <code>ISalesDataRepository</code> abstraction as the fully-composed outermost decorator:</p>
<pre><code class="language-csharp">// Innermost: the real Databricks implementation
services.AddSingleton&lt;SalesDataRepository&gt;();

// Middle layer: Blob Storage cache wrapping the real implementation
services.AddSingleton&lt;BlobStorageCachingSalesDataRepository&gt;(sp =&gt;
    new BlobStorageCachingSalesDataRepository(
        sp.GetRequiredService&lt;SalesDataRepository&gt;(),
        sp.GetRequiredService&lt;BlobServiceClient&gt;(),
        sp.GetRequiredService&lt;IOptions&lt;SalesDataBlobCacheOptions&gt;&gt;(),
        sp.GetRequiredService&lt;ILogger&lt;BlobStorageCachingSalesDataRepository&gt;&gt;(),
        sp.GetRequiredService&lt;ActivitySource&gt;()
    )
);

// Outermost: in-memory cache wrapping the blob storage cache
// This is what consumers receive when they depend on ISalesDataRepository
services.AddSingleton&lt;ISalesDataRepository&gt;(sp =&gt;
    new MemoryCachingSalesDataRepository(
        sp.GetRequiredService&lt;BlobStorageCachingSalesDataRepository&gt;(),
        sp.GetRequiredService&lt;IMemoryCache&gt;(),
        sp.GetRequiredService&lt;ActivitySource&gt;()
    )
);
</code></pre>
<p>Any component that depends on <code>ISalesDataRepository</code> via the DI container automatically gets the fully-composed chain. The decorators themselves have no knowledge of how they're composed, they just know they have an <code>ISalesDataRepository</code> to delegate to.</p>
<h2 id="tracing-a-request-through-the-cache-layers">Tracing a Request Through the Cache Layers</h2>
<p>Let's walk through what happens for a call to <code>GetProductsAsync</code> under three different scenarios.</p>
<h3 id="first-request-after-deployment-cold-everything">First Request After Deployment (Cold Everything)</h3>
<ol>
<li><strong><code>MemoryCachingSalesDataRepository</code></strong> — cache miss; delegates to inner.</li>
<li><strong><code>BlobStorageCachingSalesDataRepository</code></strong> — blob not found; acquires semaphore; delegates to inner.</li>
<li><strong><code>SalesDataRepository</code></strong> — queries Databricks SQL. If the cluster has been idle, this may take several seconds while it warms up.</li>
<li>The result flows back up: written to Blob Storage, then stored in the in-process cache.</li>
</ol>
<p>This is the expensive path. It only happens once per unique dataset per application instance.</p>
<h3 id="second-request-in-the-same-process">Second Request in the Same Process</h3>
<ol>
<li><strong><code>MemoryCachingSalesDataRepository</code></strong> — cache hit; returns immediately from memory.</li>
</ol>
<p>That's it. Sub-millisecond response time regardless of what Databricks is doing.</p>
<h3 id="first-request-after-a-replica-starts-or-a-process-restart">First Request After a Replica Starts (or a Process Restart)</h3>
<ol>
<li><strong><code>MemoryCachingSalesDataRepository</code></strong> — cache miss (new process, empty memory cache).</li>
<li><strong><code>BlobStorageCachingSalesDataRepository</code></strong> — blob found; deserialises and returns.</li>
<li>The result is held in memory for all subsequent requests.</li>
</ol>
<p>The new instance pays a Blob Storage round-trip on its first request for each dataset, but never needs to hit Databricks. The warm-up time for a new replica is a handful of Blob Storage reads rather than a cluster cold-start.</p>
<h2 id="results-and-trade-offs">Results and Trade-offs</h2>
<h3 id="what-we-gained">What We Gained</h3>
<p>After the initial warm-up, the vast majority of read requests are served from the in-memory cache in sub-millisecond time. New replicas warm quickly from Blob Storage without touching Databricks. The thundering herd problem is eliminated: Databricks is hit at most once per unique dataset per version per application instance, regardless of how many concurrent requests arrive.</p>
<h3 id="honest-trade-offs">Honest Trade-offs</h3>
<p>It would be misleading to present this as a straightforward win with no downsides. There are real trade-offs:</p>
<ul>
<li><strong>Complexity.</strong> We now have three classes where one might seem simpler at first glance. Each decorator is individually straightforward, but the chain requires understanding to navigate.</li>
<li><strong>Staleness by design.</strong> The latest version ID has a five-minute lag. The application has to tolerate that, and the product team has to accept it. In our case that's fine, new versions aren't published on a minute-by-minute basis, but it's a deliberate constraint.</li>
<li><strong>Cache invalidation is implicit.</strong> When a new version is published, the old version's blobs remain in Blob Storage, they're just never requested for that version again. A separate cleanup process could remove them if storage cost becomes a concern, but for now the cost is negligible.</li>
<li><strong>Memory pressure.</strong> Keeping large datasets in <code>IMemoryCache</code> indefinitely is a deliberate choice that works because our process's memory budget accommodates it. For larger datasets or more memory-constrained environments, you'd want to think carefully about size limits and eviction policies.</li>
<li><strong>The Blob Storage layer adds latency on cold reads.</strong> If the Databricks cluster happens to be warm when a blob is missing, going via Blob Storage is actually slower than going direct. In practice, the cluster being warm on a cold-start scenario is the exception rather than the rule, but it's worth being aware of.</li>
</ul>
<p>As always, the answer is "it depends." This approach made sense for our workload profile. For a different set of constraints, larger datasets, more frequent version changes, tighter memory budgets — some of these trade-offs might tip the other way.</p>
<h2 id="conclusions">Conclusions</h2>
<p>The Decorator pattern is a clean fit for layered caching because it keeps caching logic entirely separate from data access logic. Adding a new cache tier is additive, it doesn't require changes to existing classes. The chain is composed by the DI configuration, not by the decorators themselves.</p>
<p>The design decisions that made this work were:</p>
<ol>
<li><strong>Understanding which data is truly immutable.</strong> The versioning model gave us a strong guarantee that made aggressive, indefinite caching safe.</li>
<li><strong>Choosing the right storage tier for each layer.</strong> Blob Storage for persistence and cross-replica sharing; <code>IMemoryCache</code> for the fast path.</li>
<li><strong>Protecting against the thundering herd.</strong> The semaphore-based double-check at the Blob Storage layer is easy to overlook but critical at cold start.</li>
</ol>
<p>Databricks SQL Serverless is a powerful analytical query engine. The trick is to use it for what it's good at, processing and transforming large analytical datasets, and let fast caches absorb the high-frequency, low-latency reads that a web API demands. The Decorator pattern gives us the architectural seam to do that cleanly.</p>
<p>The same pattern applies well beyond Databricks. Anywhere you have a slow or expensive data source serving data that changes infrequently, layering caches using decorators is a maintainable and extensible approach worth considering.</p>
<p>If you've got any questions or would like to discuss anything we've talked about, please feel free to leave a comment below.</p>]]></content:encoded>
    </item>
    <item>
      <title>Fabric Performance Benchmarking - Spark versus Python Notebooks</title>
      <description>Benchmarking Pandas, PySpark, Polars, and DuckDB on Microsoft Fabric: in-process Python engines run 4-5x cheaper and faster than Spark for common workloads.</description>
      <link>https://endjin.com/blog/fabric-performance-benchmarking-spark-versus-python-notebooks</link>
      <guid isPermaLink="true">https://endjin.com/blog/fabric-performance-benchmarking-spark-versus-python-notebooks</guid>
      <pubDate>Wed, 22 Apr 2026 05:00:00 GMT</pubDate>
      <category>Microsoft Fabric</category>
      <category>Notebooks</category>
      <category>Spark</category>
      <category>Pyspark</category>
      <category>Python</category>
      <category>DuckDB</category>
      <category>SQL</category>
      <category>Polars</category>
      <category>DataFrame</category>
      <category>Data</category>
      <category>Analytics</category>
      <category>Performance</category>
      <category>Data Processing</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/fabric-performance-benchmarking-part-1-spark-versus-python-notebooks.png" />
      <dc:creator>Barry Smart</dc:creator>
      <content:encoded><![CDATA[<p><strong>TL;DR</strong> — This post analyses the results of benchmarking different data processing engines on <a href="https://www.microsoft.com/en-us/microsoft-fabric">Microsoft Fabric</a>. We compare <a href="https://pandas.pydata.org/">Pandas</a>, <a href="https://spark.apache.org/docs/latest/api/python/index.html">PySpark</a>, <a href="https://pola.rs/">Polars</a>, and <a href="https://duckdb.org/">DuckDB</a> across various compute configurations. The results provide concrete, Fabric-specific evidence for a broader industry trend: for medium-scale datasets (anything up to ~100GB), modern in-process engines like DuckDB and Polars on single-node Python notebooks are consistently faster and up to 5x cheaper than distributed Spark clusters.  The code used to generate the benchmark is <a href="https://github.com/endjin/fabric-performance-benchmark">available in a public repo on GitHub</a>.</p>
<p><a class="github-repo-card" href="https://github.com/endjin/fabric-performance-benchmark" target="_blank" rel="noopener noreferrer" data-github-repo="endjin/fabric-performance-benchmark"><span class="github-repo-card__row"><svg class="github-repo-card__logo" aria-hidden="true" viewBox="0 0 16 16" width="24" height="24" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"></path></svg><span class="github-repo-card__content"><span class="github-repo-card__title">endjin/fabric-performance-benchmark</span><span class="github-repo-card__description" data-field="description" hidden=""></span><span class="github-repo-card__meta"><span class="github-repo-card__language" data-field="language" hidden=""><span class="github-repo-card__lang-dot"></span><span data-field="language-name"></span></span><span class="github-repo-card__stars" data-field="stars" hidden=""><svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.75.75 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25z"></path></svg><span data-field="stars-count"></span></span><span class="github-repo-card__forks" data-field="forks" hidden=""><svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.878a2.25 2.25 0 1 1 1.5 0v.878a2.25 2.25 0 0 1-2.25 2.25h-1.5v2.128a2.251 2.251 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.878a2.25 2.25 0 1 1 1.5 0zM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0zm6.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm-3 8.75a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0z"></path></svg><span data-field="forks-count"></span></span></span></span><img class="github-repo-card__avatar" src="https://github.com/endjin.png?size=120" alt="endjin avatar" loading="lazy" referrerpolicy="no-referrer"></span></a></p>
<h2 id="the-broader-context-a-platform-responding-to-a-shift">The Broader Context: A Platform Responding to a Shift</h2>
<p>This benchmarking study doesn't exist in isolation. It is the latest instalment in our <a href="https://endjin.com/what-we-think/topics/polars-series/">Adventures in Polars</a> and <a href="https://endjin.com/what-we-think/topics/duckdb-series/">DuckDB</a> series, in which we have been making the case that a fundamental shift is underway in how organisations approach analytical data processing.</p>
<p>The argument, which we have explored in depth across both series, centres on what Hannes Mühleisen (co-creator of DuckDB and Professor of Data Engineering at the University of Nijmegen) calls the <strong>"data singularity"</strong>: the point at which the processing power of mainstream single-node machines surpasses the requirements of the vast majority of analytical workloads. We introduced this concept in <a href="https://endjin.com/blog/duckdb-rise-of-in-process-analytics-understanding-data-singularity">DuckDB: The Rise of In-Process Analytics and Data Singularity</a> and revisited it from a Polars perspective in <a href="https://endjin.com/blog/polars-faster-pipelines-simpler-infrastructure-happier-engineers">Why Polars Matters for Decision Makers</a>.</p>
<p>The core observation is straightforward: CPU core counts, RAM, and NVMe storage throughput have all improved dramatically over the past decade, while the size of most <em>useful</em> analytical datasets has grown far more slowly. Amazon's own internal Redshift telemetry, <a href="https://motherduck.com/blog/redshift-files-hunt-for-big-data/">analysed by MotherDuck</a>, suggests we are already close to the singularity — the 99th percentile of datasets in a production big data platform fits comfortably on a modern laptop. Their data suggests we are spending around 94% of query dollars on computation that doesn't actually need big data infrastructure.</p>
<p>The problem, historically, was that most data tools were designed <em>before</em> this shift. They could not take advantage of modern hardware capabilities. That gap gave rise to a new generation of <strong>in-process analytics engines</strong> which are built to exploit the full potential of a single, well-resourced machine. Two tools stand out: <a href="https://duckdb.org/">DuckDB</a> for those who prefer SQL, and <a href="https://pola.rs/">Polars</a> for those who prefer a DataFrame API. Both re-engineer the analytics stack from the ground up: column-oriented storage, vectorized execution across all available CPU cores, and intelligent query planning that eliminates unnecessary work. Neither requires a cluster. Neither has network overhead, authentication complexity, or the coordination costs of distributed systems.</p>
<p>The practical implication, and the hypothesis this benchmarking study was designed to test, is that for the majority of enterprise analytical workloads, these tools running on a single well-resourced node will outperform Spark at a fraction of the cost.</p>
<h2 id="microsoft-fabrics-response-the-python-notebook">Microsoft Fabric's Response: The Python Notebook</h2>
<p>It is telling that Microsoft has recognised this shift at a platform level. The introduction of <a href="https://learn.microsoft.com/en-us/fabric/data-engineering/using-python-experience-on-notebook"><strong>Python Notebooks</strong></a> to Microsoft Fabric is a direct response to the in-process analytics movement.</p>
<p>Where Fabric's Spark Notebooks provision a distributed cluster on demand (with all the associated overhead in spin-up time, coordination cost, and capacity consumption) Python Notebooks provide a single, configurable execution node. They come <strong>pre-installed with both DuckDB and Polars</strong>, a notable design choice that signals Microsoft's acknowledgement that these tools have earned their place in the enterprise data stack. Microsoft explicitly recommends both as alternatives to Pandas for memory-intensive workloads.</p>
<p>In parallel, both DuckDB and Polars have added direct support for <strong>OneLake</strong> — the storage platform that underpins Fabric. DuckDB's native Delta extension enables querying Delta tables stored in a Fabric Lakehouse via <code>delta_scan()</code>, reading directly from OneLake paths without additional configuration. Polars similarly supports reading and writing Delta tables via <code>pl.read_delta()</code>, <code>pl.scan_delta()</code>, and <code>df.write_delta()</code>, with OneLake authentication handled through Fabric's <code>notebookutils</code>. Both tools also support standard ABFS paths, enabling direct interaction with raw files in the Lakehouse Files area. We have covered these integration patterns in detail in <a href="https://endjin.com/blog/duckdb-in-practice-enterprise-integration-architectural-patterns">DuckDB Workloads on Microsoft Fabric</a> and <a href="https://endjin.com/blog/polars-workloads-on-microsoft-fabric">Polars Workloads on Microsoft Fabric</a>.</p>
<p>The Python Notebook is configurable across a range of single-node sizes (2, 4, 8, 16, 32, and 64 vCores, with memory scaling proportionally). This is a meaningful range: a 32-vCore Python Notebook gives DuckDB or Polars access to substantially more parallelism than even a well-resourced Spark executor, without any of the coordination overhead of distributed execution.</p>
<p>What follows is our attempt to put hard numbers behind these claims, using a realistic enterprise workload on real Fabric infrastructure.</p>
<h2 id="data-source">Data Source</h2>
<p>The use case is implemented using open data provided by the <a href="https://www.gov.uk/government/statistical-data-sets/price-paid-data-downloads">UK Land Registry House Price Data open data repository</a>, made available under an <a href="https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/">Open Government Licence</a>.</p>
<p>The data is provided as a set of CSV files (one per calendar year) which have been downloaded to a Fabric Lakehouse. The dataset covers property sales since 1995, with approximately 1 million transactions per year. In total, 30 years of historic data amounts to roughly 30 million rows and ~5GB of raw CSV.</p>
<p>This scale is deliberate. We find that many published benchmarks focus on processing datasets that are rarely encountered in practice. Our objective is to focus on the scale we more commonly encounter in enterprise client engagements — medium-scale datasets that are interesting enough to stress-test the differences between engines, but representative of the workloads that most data teams actually run day to day. The data fits in memory for all configurations tested, which is precisely where in-process engines are designed to excel.</p>
<h2 id="use-case">Use Case</h2>
<p>The use case mimics a common set of data transformations typical for data of this nature:</p>
<ol>
<li><strong>Start Up &amp; Set Up</strong> — provisioning the platform (Spark or Python), then completing startup tasks such as importing Python packages.</li>
<li><strong>Ingestion &amp; Transform</strong> — reading raw data from a set of CSV files, standardising, cleaning, and adding derived features.</li>
<li><strong>Write Dimensional Model</strong> — writing different slices of the transformed data to the Lakehouse in Delta format for downstream consumption, in this case as a dimensional model for Power BI.</li>
<li><strong>Read and Summarise</strong> — reading the Delta tables back and running analysis based on filtering, joining, and summarising data across different dimensions.</li>
<li><strong>Benchmark Capture &amp; Clean Up</strong> — capturing timestamps and memory consumption metrics at each stage and persisting them to the Lakehouse for analysis.</li>
</ol>
<pre class="mermaid">flowchart TB
A["1. Start Up &amp; Set Up"] --&gt; B["2. Ingestion &amp; Transform"]
B --&gt; C["3. Write Dimensional Model"]
C --&gt; D["4. Read &amp; Summarise"]
D --&gt; E["5. Benchmark Capture &amp; Clean Up"]
</pre>
<p>This pipeline exercises the full range of operations that matter to data engineers: reading raw files at scale, executing complex transformations, writing Delta tables, and querying structured data back. It is a fair test of what each engine is actually optimised to do.</p>
<h2 id="fabric-platforms">Fabric Platforms</h2>
<p>Fabric offers multiple notebook environments. For this study we used two:</p>
<ul>
<li><p><strong>Spark notebooks</strong> — a notebook experience over an on-demand Spark cluster, configurable in terms of vCores, memory, and number of executor nodes. Enables polyglot development (Python, R, SQL) across a distributed compute environment.</p>
</li>
<li><p><strong>Python notebooks</strong> — a more recent addition to Fabric. Python notebooks provision a single execution node sized according to pre-defined configurations (vCores and memory). Whilst positioned for "smaller" workloads, we find that the majority of enterprise use cases can be comfortably accommodated on this platform when the right tooling is chosen.</p>
</li>
</ul>
<h2 id="workloads">Workloads</h2>
<p>The study compares four data processing engines running the same use case on Fabric:</p>
<ol>
<li><p><strong>Pandas</strong> — the default Python library for data engineering. Vast ecosystem, but single-threaded and constrained to datasets that fit in memory. Serves as a baseline.</p>
</li>
<li><p><strong>PySpark</strong> — the Python API for Apache Spark. Designed for distributed computation across clusters and widely deployed via Databricks and Azure Synapse. The incumbent choice for enterprise-scale data engineering.</p>
</li>
<li><p><strong>Polars</strong> — a Rust-based engine with a Python API. Designed from the ground up to exploit modern hardware: automatic parallelisation across all available cores, lazy evaluation with query plan optimisation, and memory-efficient columnar processing. We explored Polars' technical foundations in detail in <a href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast">Polars Technical Deep Dive</a>.</p>
</li>
<li><p><strong>DuckDB</strong> — a C++ in-process analytical database with a Python API. Optimised for OLAP workloads, capable of querying CSV files and Delta tables directly, and able to use disk for larger-than-memory datasets. We covered DuckDB's internals — columnar storage, vectorized execution, zone maps — in <a href="https://endjin.com/blog/duckdb-in-depth-how-it-works-what-makes-it-fast">DuckDB In Depth: How It Works and What Makes It Fast</a>.</p>
</li>
</ol>
<h2 id="fabric-capacity-and-capacity-units-cus">Fabric Capacity and Capacity Units (CUs)</h2>
<p>A Fabric capacity is a dedicated pool of compute resources purchased from Azure — a fixed amount of computational horsepower continuously available to workspaces assigned to it. When you purchase a Fabric capacity (e.g. F8, F64), you are reserving that number of capacity units (CUs) for continuous use across all workloads: notebooks, pipelines, warehouses, Power BI, and so on.</p>
<p>CUs are Fabric's abstraction layer for billing compute across heterogeneous workloads. Different engines have different conversion rates, meaning your F64 capacity represents different amounts of practical compute depending on which engine is consuming it.</p>
<p>Consumption is measured in <strong>CU Seconds</strong>: the number of CUs consumed multiplied by duration in seconds. The fundamental formula for both Spark and Python Notebooks in Fabric is 0.5 CU per second per vCore, though for Spark the total vCore count must account for driver and all executor nodes:</p>
<ul>
<li>A Python Notebook sized at 8 vCores consumes <strong>4 CUs per second</strong>.</li>
<li>A Spark Notebook with 1 driver (8 vCores) and 1 executor (8 vCores) consumes <strong>8 CUs per second</strong>.</li>
</ul>
<p>This billing asymmetry is significant and, as the results below will show, it creates a strong economic case for Python Notebooks when the workload doesn't genuinely require distributed execution.</p>
<h2 id="configurations">Configurations</h2>
<p>Achieving direct parity across Spark and Python notebook platforms is not straightforward. We opted for configurations that allow a range of CU-per-second comparisons, including some like-for-like points:</p>
<table>
<thead>
<tr>
<th>CUs Per Second</th>
<th>Python Notebook Configuration</th>
<th>Spark Pool Configuration</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td><strong>2 vCores, 16G RAM</strong> [Default for Python]</td>
<td></td>
</tr>
<tr>
<td>2</td>
<td>4 vCores, 32G RAM</td>
<td></td>
</tr>
<tr>
<td>4</td>
<td>8 vCores, 64G RAM</td>
<td>1 Executor 4/4 vCores 28G/28G RAM</td>
</tr>
<tr>
<td>6</td>
<td></td>
<td>2 Executors 4/4 vCores 28G/28G RAM</td>
</tr>
<tr>
<td>8</td>
<td>16 vCores, 128G RAM</td>
<td><strong>1 Executor 8/8 vCores 56G/56G RAM</strong> [Default for Spark]</td>
</tr>
<tr>
<td>10</td>
<td></td>
<td>4 Executors 4/4 vCores 28G/28G RAM</td>
</tr>
<tr>
<td>12</td>
<td></td>
<td>2 Executors 8/8 vCores 56G/56G RAM</td>
</tr>
<tr>
<td>16</td>
<td>32 vCores, 256G RAM</td>
<td></td>
</tr>
<tr>
<td>20</td>
<td></td>
<td>4 Executors 8/8 vCores 56G/56G RAM</td>
</tr>
</tbody>
</table>
<p>The default Python Notebook (2 vCores, 16GB RAM) is <strong>8x cheaper per second</strong> to run than the default Spark Notebook (1 Executor 8/8 vCores 56G/56G RAM). The smallest Spark configuration (1 Executor 4/4 vCores 28G/28G) is equivalent in CU cost to the 8-vCore Python Notebook which provides ~2.5x the RAM, and as the results show, very significant computational throughput when running DuckDB or Polars.</p>
<p>Spin-up times are also materially different, and matter for development workflows. Python Notebooks at the default 2-vCore configuration provision in under 30 seconds. Spark clusters in this study took between 3 and 3.5 minutes at all configurations tested:</p>
<table>
<thead>
<tr>
<th style="text-align: left;">Platform</th>
<th style="text-align: left;">Configuration</th>
<th style="text-align: right;">Median Provisioning Time</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;"><strong>Fabric PySpark Notebook</strong> [Default for Spark]</td>
<td style="text-align: left;">01 executors 08/08 cores 56g/56g memory</td>
<td style="text-align: right;">18.6</td>
</tr>
<tr>
<td style="text-align: left;"><strong>Fabric Python Notebook</strong> [Default for Python]</td>
<td style="text-align: left;">02 vCores</td>
<td style="text-align: right;">24.356</td>
</tr>
<tr>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">16 vCores</td>
<td style="text-align: right;">125.15</td>
</tr>
<tr>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">08 vCores</td>
<td style="text-align: right;">129.215</td>
</tr>
<tr>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">32 vCores</td>
<td style="text-align: right;">132.767</td>
</tr>
<tr>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">04 vCores</td>
<td style="text-align: right;">136.243</td>
</tr>
<tr>
<td style="text-align: left;">Fabric PySpark Notebook</td>
<td style="text-align: left;">02 executors 08/08 cores 56g/56g memory</td>
<td style="text-align: right;">188.749</td>
</tr>
<tr>
<td style="text-align: left;">Fabric PySpark Notebook</td>
<td style="text-align: left;">04 executors 08/08 cores 56g/56g memory</td>
<td style="text-align: right;">192.767</td>
</tr>
<tr>
<td style="text-align: left;">Fabric PySpark Notebook</td>
<td style="text-align: left;">01 executors 04/04 cores 28g/28g memory</td>
<td style="text-align: right;">194.956</td>
</tr>
</tbody>
</table>
<p>When developing directly in Fabric notebooks we use the default platform configurations operating on test data.  Then scale up the configuration as needed in production.</p>
<p>With DuckDB and Polars, we favour local development given the access this gives us to modern IDEs, coding agents and unit testing frameworks.  Faster provisioning directly improves the developer inner loop. Shorter iteration cycles during development compound over the course of a project. This is consistent with our experience migrating client workloads from Spark to in-process engines, where test suite runtimes dropped from minutes to seconds and local development became a practical reality again.</p>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/fabric-notebook-spin-up-times.png"><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/fabric-notebook-spin-up-times.png" alt="Boxplot visualizing the distribution of spin up times for different configurations, showing Python notebooks are significantly faster to provision than Spark notebooks." title="Boxplot visualizing the distribution of spin up times for different configurations, showing Python notebooks are significantly faster to provision than Spark notebooks." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/03/fabric-notebook-spin-up-times.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/03/fabric-notebook-spin-up-times.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/03/fabric-notebook-spin-up-times.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/03/fabric-notebook-spin-up-times.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></a></p>
<h2 id="methodology">Methodology</h2>
<p>Multiple runs were completed for each combination of engine, workload, and configuration. Median execution times are reported throughout to reduce the influence of outliers.</p>
<h2 id="analysis">Analysis</h2>
<h3 id="elapsed-time-analysis">Elapsed Time Analysis</h3>
<p>Elapsed time analysis <strong>includes</strong> the time to spin up the required Spark or Python environment. Environments with faster provisioning therefore have an inherent advantage here. Note that spin-up time does not incur a CU cost on Fabric — if cost is your primary concern, skip ahead to the Execution Time Analysis.</p>
<p>The table below shows median elapsed times for each workload on its default environment:</p>
<table>
<thead>
<tr>
<th style="text-align: left;">Platform</th>
<th style="text-align: left;">Configuration</th>
<th style="text-align: left;">Workload</th>
<th style="text-align: right;">CUs Per Second</th>
<th style="text-align: right;">Median Elapsed Time</th>
<th style="text-align: right;">Percentage of Min Elapsed Time</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;">Fabric PySpark Notebook</td>
<td style="text-align: left;">01 executors 08/08 cores 56g/56g memory</td>
<td style="text-align: left;">pyspark_benchmark</td>
<td style="text-align: right;">8</td>
<td style="text-align: right;">125.691</td>
<td style="text-align: right;">100</td>
</tr>
<tr>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">02 vCores</td>
<td style="text-align: left;">duckdb_benchmark</td>
<td style="text-align: right;">1</td>
<td style="text-align: right;">133.59</td>
<td style="text-align: right;">106.3</td>
</tr>
<tr>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">02 vCores</td>
<td style="text-align: left;">polars_benchmark</td>
<td style="text-align: right;">1</td>
<td style="text-align: right;">174.876</td>
<td style="text-align: right;">139.1</td>
</tr>
<tr>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">02 vCores</td>
<td style="text-align: left;">pandas_benchmark</td>
<td style="text-align: right;">1</td>
<td style="text-align: right;">275.515</td>
<td style="text-align: right;">219.2</td>
</tr>
</tbody>
</table>
<p>Key observations:</p>
<ul>
<li>PySpark (on its default 8-CU environment) is the fastest at elapsed time (~126 seconds), but only marginally so.</li>
<li>DuckDB on the default Python Notebook (1 CU per second) runs in ~134 seconds — just 6% slower — at <strong>one-eighth the CU cost</strong>.</li>
<li>Polars on the same minimal configuration completes in ~175 seconds, also at one-eighth the cost.</li>
<li>Pandas, the incumbent default, takes over four minutes — roughly twice Spark's elapsed time.</li>
</ul>
<h3 id="execution-time-analysis">Execution Time Analysis</h3>
<p>Execution time <strong>excludes</strong> spin-up and environment provisioning, enabling a direct comparison between engines across configurations at equivalent CU costs.</p>
<p>The <strong>Top 10</strong> results are presented below:</p>
<ul>
<li>DuckDB achieves the fastest pure execution time, on an 8-vCore Python Notebook with 64GB RAM.</li>
<li>DuckDB and Polars occupy 8 of the top 10 positions before Spark appears.</li>
<li>The fastest Spark execution time is more than twice that of DuckDB — which achieves its best result on infrastructure with half the CU cost.</li>
</ul>
<table>
<thead>
<tr>
<th style="text-align: right;">Rank</th>
<th style="text-align: left;">Platform</th>
<th style="text-align: left;">Configuration</th>
<th style="text-align: left;">Workload</th>
<th style="text-align: right;">CUs Per Second</th>
<th style="text-align: right;">Median Execution Time</th>
<th style="text-align: right;">Percentage of Min Execution Time</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: right;">1</td>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">08 vCores</td>
<td style="text-align: left;">duckdb_benchmark</td>
<td style="text-align: right;">4</td>
<td style="text-align: right;">47.037</td>
<td style="text-align: right;">100</td>
</tr>
<tr>
<td style="text-align: right;">2</td>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">04 vCores</td>
<td style="text-align: left;">duckdb_benchmark</td>
<td style="text-align: right;">2</td>
<td style="text-align: right;">66.405</td>
<td style="text-align: right;">141.2</td>
</tr>
<tr>
<td style="text-align: right;">3</td>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">16 vCores</td>
<td style="text-align: left;">duckdb_benchmark</td>
<td style="text-align: right;">8</td>
<td style="text-align: right;">72.794</td>
<td style="text-align: right;">154.8</td>
</tr>
<tr>
<td style="text-align: right;">4</td>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">32 vCores</td>
<td style="text-align: left;">duckdb_benchmark</td>
<td style="text-align: right;">16</td>
<td style="text-align: right;">80.182</td>
<td style="text-align: right;">170.5</td>
</tr>
<tr>
<td style="text-align: right;">5</td>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">16 vCores</td>
<td style="text-align: left;">polars_benchmark</td>
<td style="text-align: right;">8</td>
<td style="text-align: right;">86.28</td>
<td style="text-align: right;">183.4</td>
</tr>
<tr>
<td style="text-align: right;">6</td>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">08 vCores</td>
<td style="text-align: left;">polars_benchmark</td>
<td style="text-align: right;">4</td>
<td style="text-align: right;">88.203</td>
<td style="text-align: right;">187.5</td>
</tr>
<tr>
<td style="text-align: right;">7</td>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">32 vCores</td>
<td style="text-align: left;">polars_benchmark</td>
<td style="text-align: right;">16</td>
<td style="text-align: right;">90.787</td>
<td style="text-align: right;">193</td>
</tr>
<tr>
<td style="text-align: right;">8</td>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">04 vCores</td>
<td style="text-align: left;">polars_benchmark</td>
<td style="text-align: right;">2</td>
<td style="text-align: right;">102.398</td>
<td style="text-align: right;">217.7</td>
</tr>
<tr>
<td style="text-align: right;">9</td>
<td style="text-align: left;">Fabric PySpark Notebook</td>
<td style="text-align: left;">01 executors 08/08 cores 56g/56g memory</td>
<td style="text-align: left;">pyspark_benchmark</td>
<td style="text-align: right;">8</td>
<td style="text-align: right;">107.091</td>
<td style="text-align: right;">227.7</td>
</tr>
<tr>
<td style="text-align: right;">10</td>
<td style="text-align: left;">Fabric Python Notebook</td>
<td style="text-align: left;">02 vCores</td>
<td style="text-align: left;">duckdb_benchmark</td>
<td style="text-align: right;">1</td>
<td style="text-align: right;">107.871</td>
<td style="text-align: right;">229.3</td>
</tr>
</tbody>
</table>
<p>The line chart below shows median execution times across all engines and configurations, with CUs per second as the common measure of both environment size and cost:</p>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/fabric-notebook-execution-time.png"><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/fabric-notebook-execution-time.png" alt="Line chart showing median execution times for each engine, demonstrating that DuckDB and Polars outperform PySpark at lower CU levels." title="Line chart showing median execution times for each engine, demonstrating that DuckDB and Polars outperform PySpark at lower CU levels." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/03/fabric-notebook-execution-time.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/03/fabric-notebook-execution-time.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/03/fabric-notebook-execution-time.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/03/fabric-notebook-execution-time.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></a></p>
<p>Key observations:</p>
<ul>
<li>Execution time for all engines initially decreases as more cores and memory become available, but there is a clear <strong>"sweet spot"</strong> beyond which performance plateaus or even degrades slightly. Throwing more infrastructure at a problem does not guarantee faster results and can actually make things slower.</li>
<li>At comparable CU levels (e.g. 4 CUs per second), DuckDB and Polars on Python Notebooks significantly outperform PySpark on Spark Notebooks.</li>
</ul>
<p>This "sweet spot" behaviour is consistent with what we know about how DuckDB and Polars are engineered. Both tools use automatic parallelisation across available cores, but there is an overhead to thread coordination that grows with core count. Beyond the point where all available parallelism is fully utilised, adding more cores yields diminishing returns.</p>
<h2 id="cu-cost-analysis">CU Cost Analysis</h2>
<p>Shifting focus from execution time to cost, the following table lists the 10 cheapest engine/configuration combinations:</p>
<table>
<thead>
<tr>
<th style="text-align: left;">Workload</th>
<th style="text-align: left;">Configuration</th>
<th style="text-align: right;">CUs Per Second</th>
<th style="text-align: right;">Median Execution Time</th>
<th style="text-align: right;">Total Cost (CUs)</th>
<th style="text-align: right;">Percentage of Min Cost</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;">duckdb_benchmark</td>
<td style="text-align: left;">02 vCores</td>
<td style="text-align: right;">1</td>
<td style="text-align: right;">107.871</td>
<td style="text-align: right;">107.871</td>
<td style="text-align: right;">100</td>
</tr>
<tr>
<td style="text-align: left;">duckdb_benchmark</td>
<td style="text-align: left;">04 vCores</td>
<td style="text-align: right;">2</td>
<td style="text-align: right;">66.405</td>
<td style="text-align: right;">132.81</td>
<td style="text-align: right;">123.1</td>
</tr>
<tr>
<td style="text-align: left;">polars_benchmark</td>
<td style="text-align: left;">02 vCores</td>
<td style="text-align: right;">1</td>
<td style="text-align: right;">156.885</td>
<td style="text-align: right;">156.885</td>
<td style="text-align: right;">145.4</td>
</tr>
<tr>
<td style="text-align: left;">duckdb_benchmark</td>
<td style="text-align: left;">08 vCores</td>
<td style="text-align: right;">4</td>
<td style="text-align: right;">47.037</td>
<td style="text-align: right;">188.148</td>
<td style="text-align: right;">174.4</td>
</tr>
<tr>
<td style="text-align: left;">polars_benchmark</td>
<td style="text-align: left;">04 vCores</td>
<td style="text-align: right;">2</td>
<td style="text-align: right;">102.398</td>
<td style="text-align: right;">204.796</td>
<td style="text-align: right;">189.9</td>
</tr>
<tr>
<td style="text-align: left;">pandas_benchmark</td>
<td style="text-align: left;">02 vCores</td>
<td style="text-align: right;">1</td>
<td style="text-align: right;">250.84</td>
<td style="text-align: right;">250.84</td>
<td style="text-align: right;">232.5</td>
</tr>
<tr>
<td style="text-align: left;">polars_benchmark</td>
<td style="text-align: left;">08 vCores</td>
<td style="text-align: right;">4</td>
<td style="text-align: right;">88.203</td>
<td style="text-align: right;">352.812</td>
<td style="text-align: right;">327.1</td>
</tr>
<tr>
<td style="text-align: left;">pandas_benchmark</td>
<td style="text-align: left;">04 vCores</td>
<td style="text-align: right;">2</td>
<td style="text-align: right;">224.122</td>
<td style="text-align: right;">448.244</td>
<td style="text-align: right;">415.5</td>
</tr>
<tr>
<td style="text-align: left;">duckdb_benchmark</td>
<td style="text-align: left;">16 vCores</td>
<td style="text-align: right;">8</td>
<td style="text-align: right;">72.794</td>
<td style="text-align: right;">582.352</td>
<td style="text-align: right;">539.9</td>
</tr>
<tr>
<td style="text-align: left;">pyspark_benchmark</td>
<td style="text-align: left;">01 executors 04/04 cores 28g/28g memory</td>
<td style="text-align: right;">4</td>
<td style="text-align: right;">149.619</td>
<td style="text-align: right;">598.478</td>
<td style="text-align: right;">554.8</td>
</tr>
</tbody>
</table>
<p>The cheapest Spark run costs more than 5x the cheapest DuckDB run and approximately 4x the cheapest Polars run. Even Pandas on the default minimal Python Notebook is cheaper than the most economical Spark configuration.</p>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/fabric-notebook-costs.png"><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/fabric-notebook-costs.png" alt="Chart displaying the relative costs of different engine and configuration combinations, highlighting the cost-effectiveness of Python notebooks." title="Chart displaying the relative costs of different engine and configuration combinations, highlighting the cost-effectiveness of Python notebooks." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/03/fabric-notebook-costs.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/03/fabric-notebook-costs.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/03/fabric-notebook-costs.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/03/fabric-notebook-costs.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></a></p>
<h2 id="execution-time-versus-cost">Execution Time versus Cost</h2>
<p>The following scatter chart provides a two-dimensional view of all engine/configuration combinations tested. The horizontal x-axis shows execution time; the vertical y-axis shows cost on a logarithmic scale.</p>
<p>The sweet spot (best combination of fast execution and low cost) lies in the <strong>bottom-left quadrant</strong>. That space is dominated by DuckDB and Polars.</p>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/fabric-notebook-costs-versus-execution-time.png"><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/fabric-notebook-costs-versus-execution-time.png" alt="Scatter plot of execution time versus cost, showing the 'sweet spot' dominated by DuckDB and Polars in the bottom-left quadrant." title="Scatter plot of execution time versus cost, showing the &quot;sweet spot&quot; dominated by DuckDB and Polars in the bottom-left quadrant." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/03/fabric-notebook-costs-versus-execution-time.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/03/fabric-notebook-costs-versus-execution-time.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/03/fabric-notebook-costs-versus-execution-time.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/03/fabric-notebook-costs-versus-execution-time.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></a></p>
<h2 id="stage-analysis-what-separates-duckdb-from-polars">Stage Analysis: What Separates DuckDB from Polars?</h2>
<p>Both DuckDB and Polars are modern in-process query engines built for exactly this kind of workload, yet DuckDB consistently comes out ahead. The stage-level analysis reveals where that advantage is won.</p>
<p>DuckDB's edge appears consistently in the stages that involve <strong>reading from the Fabric Lakehouse</strong> — specifically, reading Delta format tables. DuckDB uses its own native Delta extension for this purpose, reading directly from OneLake without additional dependencies. Polars, by contrast, currently uses the <code>delta-rs</code> package for Delta reads.</p>
<p>This is an important distinction in the context of Microsoft Fabric's architecture. OneLake stores data in Delta format, and any engine that can query Delta tables natively has a structural advantage. It is worth watching whether Polars develops its own native Delta reader over time; if it does, the gap between the two engines may narrow.</p>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/fabric-notebook-execution-stage-cumulative-time.png"><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/fabric-notebook-execution-stage-cumulative-time.png" alt="Stage-level analysis comparing DuckDB and Polars, revealing DuckDB's advantage in reading from the Fabric lakehouse." title="Stage-level analysis comparing DuckDB and Polars, revealing DuckDB's advantage in reading from the Fabric lakehouse." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/03/fabric-notebook-execution-stage-cumulative-time.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/03/fabric-notebook-execution-stage-cumulative-time.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/03/fabric-notebook-execution-stage-cumulative-time.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/03/fabric-notebook-execution-stage-cumulative-time.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></a></p>
<h2 id="conclusions">Conclusions</h2>
<p>This benchmarking study provides concrete, Fabric-specific evidence for what we have been arguing across the broader series: in-process analytics engines running on single-node infrastructure are a serious and, for most workloads, superior alternative to distributed Spark.</p>
<ol>
<li><p><strong>Modern in-process engines outperform Spark for medium-scale workloads</strong> — DuckDB and Polars delivered faster execution times than PySpark across all comparable configurations, often by a factor of 2x or more. The claims we made in our <a href="https://endjin.com/blog/duckdb-rise-of-in-process-analytics-understanding-data-singularity">DuckDB</a> and <a href="https://endjin.com/blog/polars-faster-pipelines-simpler-infrastructure-happier-engineers">Polars</a> series hold up under real Fabric workloads.</p>
</li>
<li><p><strong>Cost efficiency strongly favours Python Notebooks with modern engines</strong> — the cheapest Spark configuration costs 4-5x more than the cheapest DuckDB run for equivalent work. For teams with finite Fabric capacity budgets, this is a material consideration. Profile your workload, start small, and scale to Spark only when you genuinely need it.</p>
</li>
<li><p><strong>More resources don't always mean faster execution</strong> — there is a "sweet spot" for resource allocation beyond which performance plateaus or degrades. This challenges the intuition that scaling infrastructure will proportionally improve throughput. Both DuckDB and Polars are efficient enough that 4-8 vCores often delivers the best balance of speed and cost.</p>
</li>
<li><p><strong>Default configurations are not equal — and the gap matters</strong> — the default Python Notebook (2 vCores) is 8x cheaper per second to run than the default Spark Notebook, yet delivers comparable elapsed-time performance when using DuckDB or Polars. For development workloads, the Python Notebook should be the default choice.</p>
</li>
<li><p><strong>DuckDB's native Delta reader provides a measurable edge</strong> — the stage analysis suggests DuckDB's advantage over Polars comes primarily from its native Delta reading capability. This is a meaningful finding in the context of Fabric, where Delta is the dominant table format. It reinforces one of DuckDB's core design principles: eliminate friction wherever data meets compute.</p>
</li>
<li><p><strong>Microsoft is responding to the in-process analytics movement</strong> — the introduction of Python Notebooks pre-installed with DuckDB and Polars, combined with OneLake support in both tools, signals that the platform is evolving to accommodate this shift. Teams investing in these tools are aligned with the direction of travel, not swimming against it.</p>
</li>
</ol>
<p>Note - all code used to generate this benchmark and the supporting analysis is <a href="https://github.com/endjin/fabric-performance-benchmark">available in a public repo on GitHub</a>.</p>
<p><a class="github-repo-card" href="https://github.com/endjin/fabric-performance-benchmark" target="_blank" rel="noopener noreferrer" data-github-repo="endjin/fabric-performance-benchmark"><span class="github-repo-card__row"><svg class="github-repo-card__logo" aria-hidden="true" viewBox="0 0 16 16" width="24" height="24" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"></path></svg><span class="github-repo-card__content"><span class="github-repo-card__title">endjin/fabric-performance-benchmark</span><span class="github-repo-card__description" data-field="description" hidden=""></span><span class="github-repo-card__meta"><span class="github-repo-card__language" data-field="language" hidden=""><span class="github-repo-card__lang-dot"></span><span data-field="language-name"></span></span><span class="github-repo-card__stars" data-field="stars" hidden=""><svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.75.75 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25z"></path></svg><span data-field="stars-count"></span></span><span class="github-repo-card__forks" data-field="forks" hidden=""><svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.878a2.25 2.25 0 1 1 1.5 0v.878a2.25 2.25 0 0 1-2.25 2.25h-1.5v2.128a2.251 2.251 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.878a2.25 2.25 0 1 1 1.5 0zM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0zm6.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm-3 8.75a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0z"></path></svg><span data-field="forks-count"></span></span></span></span><img class="github-repo-card__avatar" src="https://github.com/endjin.png?size=120" alt="endjin avatar" loading="lazy" referrerpolicy="no-referrer"></span></a></p>
<p>The data singularity is not a theoretical future state — it is arriving now, and platforms like Microsoft Fabric are starting to reflect that. For most enterprise analytical workloads, the question is no longer whether single-node in-process engines can handle the job. Based on the evidence here, they can deliver a faster and cheaper solution than the distributed alternative.</p>
<p>So which workloads do still justify Spark's overhead?  Reserve Spark for datasets that genuinely exceed single-node memory capacity, or for workloads where Fabric-specific Spark optimisations (V-ORDER, Liquid Clustering) are demonstrably valuable. For everything else, DuckDB and Polars on a Python Notebook are the pragmatic choice.</p>
<p>The good news is that Microsoft Fabric makes all of these compute options available to you, underpinned by Delta format and <a href="https://learn.microsoft.com/en-us/fabric/onelake/onelake-overview">OneLake</a> as the common storage layer.  So you are not forced to make a choice up front, you have the flexibility to adapt without being forced to move data or adopt a different platform.</p>]]></content:encoded>
    </item>
    <item>
      <title>Medallion Architecture in Excel</title>
      <description>Apply the Medallion Architecture to Excel: use the three-tab rule to separate raw data, logic, and output for cleaner, maintainable spreadsheets.</description>
      <link>https://endjin.com/blog/medallion-architecture-in-excel</link>
      <guid isPermaLink="true">https://endjin.com/blog/medallion-architecture-in-excel</guid>
      <pubDate>Tue, 21 Apr 2026 05:30:00 GMT</pubDate>
      <category>Data</category>
      <category>Risk</category>
      <category>Reporting</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/medallion-architecture-in-excel.png" />
      <dc:creator>James Broome</dc:creator>
      <content:encoded><![CDATA[<h2 id="the-three-tab-rule">The three-tab rule</h2>
<p>I've worked in technology for a long time. In fact, I've had a 25-year long career in software and data, which means I've had to use a lot of different programming languages, frameworks, platforms and tech stacks. But it's also meant that I've used Excel a lot. I would describe myself as fairly competent in Excel. Not <a href="https://www.bbc.co.uk/news/articles/cj4qzgvxxgvo">world championship level</a> by any means, and probably not even Joel Spolsky level (although I did <a href="https://www.youtube.com/watch?v=JxBg4sMusIg">start to suck slightly less after watching this 10 years ago</a>). But fairly competent.</p>
<p>However, I came across an interesting article recently that really grabbed my attention. <a href="https://www.howtogeek.com/microsoft-excel-3-tab-rule-structure-spreadsheet-like-a-software-developer/">This post on How-To Geek describes a "three-tab rule" in Excel</a>, separating source data, logic, and presentation. It's framed by making comparisons to the Model-View-Controller (MVC) pattern in web development, something I'm very familiar with, but had never considered applying to Excel. I was pretty surprised that I'd never come across this before (especially as I've spent a lot of time thinking about engineering practices in software), but I was also immediately struck by how useful, sensible and simple it was.</p>
<p>The pattern described proposes structuring Excel workbooks using three distinct tabs: Source, Logic, and Interface. The premise is straightforward - most spreadsheets fail because they mix raw data, calculations, and final reports on the same screen. By separating these concerns, you create workbooks that are easier to audit, maintain, and scale.</p>
<p>The MVC-based pattern works like this: the Source tab holds your raw, unmodified data in a structured format (ideally as an Excel Table). The Logic tab does all the heavy lifting - transformations, calculations, lookups, using modern Excel functions like FILTER, SORT, and LAMBDA. The Interface tab presents the final, polished output that stakeholders actually see.</p>
<p>This resonates strongly with how we think about data architecture at endjin. But while the original article frames this through the Model-View-Controller pattern, I think there's a more relevant mental model from the data engineering world that fits this use case perfectly.</p>
<h2 id="from-mvc-to-medallion">From MVC to Medallion</h2>
<p>If you've worked with modern data platforms - whether that's Databricks, Microsoft Fabric, or Azure Synapse - you'll likely be familiar with the Medallion Architecture. As Carmel described in her <a href="https://endjin.com/blog/2025/05/what-is-the-medallion-architecture">recent deep-dive on the topic</a>, it's a data design pattern that consists of three tiers: Bronze (raw), Silver (cleaned and validated), and Gold (projected for specific use cases).</p>
<p>The parallel to the three-tab Excel rule is obvious:</p>
<table>
<thead>
<tr>
<th>Medallion Tier</th>
<th>Excel Tab</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Bronze</strong></td>
<td>Source</td>
<td>Raw data in its original form. No transformations, no cosmetic formatting. A historical archive of what was received.</td>
</tr>
<tr>
<td><strong>Silver</strong></td>
<td>Clean</td>
<td>Cleaned, validated, and structured data. Schema standardisation. This is where raw data becomes useful information.</td>
</tr>
<tr>
<td><strong>Gold</strong></td>
<td>Output</td>
<td>Transformed and projected for a specific use case. Logic and calculations applied. Polished, formatted, and ready for consumption by stakeholders. This might be a pivot table, a chart, or another targeted, tabular presentation.</td>
</tr>
</tbody>
</table>
<p>This isn't just a semantic rebrand. The Medallion Architecture brings with it a wealth of best practices from the data engineering community - around data quality, validation, lineage, and governance that can inform how you approach your data workloads, even in Excel.</p>
<h2 id="the-benefits-of-this-approach">The benefits of this approach</h2>
<p>When you start thinking about your Excel workbook as a miniature data pipeline, several good practices naturally follow.</p>
<p><strong>Data lineage becomes visible.</strong> With clear separation, you can trace any value in your Output tab back through the Clean tab to its origin in the Source tab. When a number looks wrong, you know where to look.</p>
<p><strong>Validation can be added systematically.</strong> The Clean tab becomes the natural place to add data quality checks - are there unexpected blanks, or duplicates? Do totals reconcile? Are values within expected ranges? This is the equivalent of creating quality gates as data moves from Bronze to Silver.</p>
<p><strong>The Source tab becomes immutable.</strong> Just as the Bronze tier in a data lakehouse preserves raw data for historical playback, your Source tab should remain untouched. If new data arrives, append it rather than overwrite. This gives you an audit trail and the ability to reprocess if your logic changes.</p>
<p><strong>Multiple projections from the same source.</strong> You might need different views of the same underlying data - one for the finance team, one for operations, one for the board. In the Medallion Architecture, this is exactly what the Gold tier enables. In Excel, you can create multiple Output tabs, all drawing from the same Clean layer.</p>
<h2 id="proceed-with-caution">Proceed with caution</h2>
<p>I should be clear that whilst this pattern makes Excel workbooks more robust, more maintainable, and more professional, it doesn't make Excel the right tool for every job. <a href="https://endjin.com/blog/2020/10/the-public-health-england-october-2020-test-and-trace-excel-error-could-have-been-prevented-by-this-one-simple-step">I hold some strong opinions about when and where Excel is appropriate</a>, and when the stakes are high - when significant decisions are being made and errors could impact financial, regulatory compliance, or even public health outcomes, Excel isn't enough. You more than likely need proper software engineering discipline and quality control barriers across technology, process, and people. This pattern doesn't prevent someone accidentally filtering and deleting rows in the Source tab. It doesn't stop formulas breaking silently. It doesn't provide version control, automated testing, or audit logs.</p>
<p>But, for those situations where Excel genuinely is appropriate, or where constraints mean it's your only option, this approach represents a genuine step-change in how you structure your work.</p>
<h2 id="summary">Summary</h2>
<p>The three-tab rule for Excel is a pattern worth adopting. It has a direct comparison to the well established Medallion Architecture, connecting your Excel work to the broader principles of modern data engineering: separation of concerns, data quality, lineage, and fit-for-purpose projections.</p>
<p>Of course, this approach doesn't come without limitations. Excel remains a tool designed for individual productivity, not enterprise data management. If your workbook is becoming mission-critical - if it updates regularly, if others depend on it, if errors would be costly, then it's worth asking whether you've outgrown what Excel can safely provide.</p>]]></content:encoded>
    </item>
    <item>
      <title>LINQ Max and nullable value types</title>
      <description>LINQ's projecting Max operator has a trap for the unwary when used with value types. Understand what goes wrong, and how to avoid it.</description>
      <link>https://endjin.com/blog/csharp-linq-max-nullable-values</link>
      <guid isPermaLink="true">https://endjin.com/blog/csharp-linq-max-nullable-values</guid>
      <pubDate>Fri, 17 Apr 2026 04:30:35 GMT</pubDate>
      <category>Nullable Reference Types</category>
      <category>Nullable Reference Types in C#</category>
      <category>Nullable Types in C#</category>
      <category>C# Nullable Types</category>
      <category>Nullable</category>
      <category>Null Reference Exceptions</category>
      <category>Nullable Values</category>
      <category>NRTs</category>
      <category>NRT</category>
      <category>non-nullable</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>dotnet</category>
      <category>Visual Studio</category>
      <category>Visual Studio Code</category>
      <category>C# Tutorials</category>
      <category>C# Programming</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/csharp-linq-max-nullable-values.png" />
      <dc:creator>Ian Griffiths</dc:creator>
      <content:encoded><![CDATA[<p>While working on a project for a customer, we came across a slight oddity of LINQ's <a href="https://learn.microsoft.com/dotnet/api/system.linq.enumerable.max"><code>Max</code></a> operator when you use it with a value type. In some cases <code>Max</code> returns <code>null</code> when supplied with an empty list, and there are cases where this works even with value types—<a href="https://learn.microsoft.com/en-gb/dotnet/api/system.linq.enumerable.max?view=net-10.0#system-linq-enumerable-max-1(system-collections-generic-ienumerable((-0))-system-func((-0-system-nullable((system-int32)))))">this overload</a> returns <code>int?</code> for example. But in some cases it will not do this, and will instead throw an exception if its input is empty. The reasons behind it are non-obvious and somewhat subtle, so I thought I'd write about it.</p>
<p>The <code>Max</code> operator offers a <a href="https://learn.microsoft.com/dotnet/api/system.linq.enumerable.max#system-linq-enumerable-max-2(system-collections-generic-ienumerable((-0))-system-func((-0-1)))">projection-based overload</a> with this signature:</p>
<pre><code class="language-cs">public static TResult? Max&lt;TSource,TResult&gt;(
    this IEnumerable&lt;TSource&gt; source,
    Func&lt;TSource,TResult&gt; selector);
</code></pre>
<p>This will iterate through the <code>source</code>, pass each item to the <code>selector</code> callback, and then return the highest of the values the callback returns.</p>
<p>Notice how although the selector function returns a <code>TResult</code>, the return type of <code>Max</code> itself is <code>TResult?</code>. That nullability is there to handle the case where the <code>source</code> enumerable is empty: in that case there is no maximum value (because there are no values at all) and that <code>TResult?</code> return type means <code>Max</code> can return <code>null</code> to indicate that.</p>
<p>But it goes a bit weird if the <code>selector</code> returns a value type. Suppose you've got this type (which is a reference type, but crucially, two of its properties use value types):</p>
<pre><code class="language-cs">public record WithValues(string Label, int Number, DateTimeOffset Date);
</code></pre>
<p>First, let's verify that <code>Max</code> does what I've said with an empty list when the projection retrieves a reference type:</p>
<pre><code class="language-cs">WithValues[] empty = [];
string? maxLabel = empty.Max(x =&gt; x.Label);
Console.WriteLine(maxLabel is null);
</code></pre>
<p>This prints out <code>True</code>, confirming that <code>Max</code> here does indeed return <code>null</code> to let us know that there was no maximum value. (The notion of a <em>maximum string value</em> raises the awkward fact that <code>Max</code> doesn't let you pass an <code>IComparer&lt;T&gt;</code> here, but let's ignore that for now.)</p>
<p>With that in mind, what type do you suppose <code>maxDate</code> has in this example?</p>
<pre><code class="language-cs">WithValues[] empty = [];
var maxDate = empty.Max(d =&gt; d.Date);
</code></pre>
<p>If you look at the definition of <code>Max</code> you could correctly conclude that <code>TSource</code> here becomes <code>WithValues</code> and that <code>TResult</code> is <code>DateTimeOffset</code>. (And as we're doing all this type inference in our heads, we might reflect on whether using <code>var</code> here has really saved us any time and effort.) And since <code>Max</code> returns <code>TResult?</code> you might conclude that <code>maxDate</code> must be of type <code>DateTimeOffset?</code> (which is an alias for <code>Nullable&lt;DateTimeOffset&gt;</code>).</p>
<p>But that would be wrong. Here's exactly equivalent code using an explicit type declaration instead of <code>var</code>:</p>
<pre><code class="language-cs">WithValues[] empty = [];
DateTimeOffset maxDate = empty.Max(d =&gt; d.Date);
</code></pre>
<p>It is now clear that <code>maxDate</code>'s type is <code>DateTimeOffset</code>. If we were to try to declare it as a <code>DateTimeOffset?</code>, that would actually compile, but it wouldn't be equivalent to the <code>var</code> example: in the case where we use <code>var</code>, <code>maxDate</code> really does have the non-nullable <code>DateTimeOffset</code> type.</p>
<p>And if we do try to use <code>DateTimeOffset?</code>, it goes wrong. This compiles:</p>
<pre><code class="language-cs">DateTimeOffset? maxDate = empty.Max(d =&gt; d.Date);
if (maxDate.HasValue)
{
    Console.WriteLine(maxDate.Value);
}
else
{
    Console.WriteLine("No dates found.");
}
</code></pre>
<p>but it only compiles without error because an implicit conversion is available from <code>Max</code>'s return type of <code>DateTimeOffset</code> to the variable's type of <code>DateTimeOffset?</code>.</p>
<p>The most important thing to know about this code is that it will actually fail at runtime with an <code>InvalidOperationException</code> complaining that the <code>Sequence contains no elements</code>!</p>
<p>Earlier I linked to a non-generic overload of <code>Max</code> that returns an <code>int?</code> so you might think that this would work:</p>
<pre><code class="language-cs">WithValues[] empty = [];
int? maxNumber = empty.Max(x =&gt; x.Number);
</code></pre>
<p>but this will also fail with an exception at runtime instead of returning <code>null</code>. In fact it ends up using a different overload that returns an <code>int</code>, and not the one that returns an <code>int?</code>.</p>
<p>So that's weird. The first two examples call the same single overload of <code>Max</code>, and yet it handles an empty list completely differently depending on whether our selector returns the <code>Label</code> or <code>Date</code>. (When it returns <code>Number</code>, we end up using the <code>int</code>-specific overload, but the fact remains that an empty list causes an exception when we select a value-typed property, but the method returns <code>null</code> when selecting a reference-typed property.) What's going on?</p>
<p>Well it turns out that this particular <code>Max</code> method actually has two different code paths: and it effectively uses this test to choose which path to use:</p>
<pre><code class="language-cs">TResult val = default;
if (val == null)
...
</code></pre>
<p>If <code>val == null</code>, then it goes down the code path that returns <code>null</code> if the list is empty. If not, it goes down the path that throws an exception if the list is empty.</p>
<p>This is a deliberate design choice. If <code>default(TResult)</code> is something other than <code>null</code>—e.g. <code>default(int)</code> is 0—then there might be no way to tell the difference between an empty list, and a list where <code>default(TResult)</code> really was the maximum value. For example, in the list <code>[-3,-2,-1,0]</code>, the maximum value is <code>0</code>, so how could we distinguish between that case and the empty list case if we were getting back <code>0</code> in either case?</p>
<p>So there's a rationale for this behaviour, but it's not obvious that this one method can behave in two quite different ways. The documentation doesn't mention that this particular overload may throw an <code>InvalidOperationException</code>.</p>
<p>We can explore what that test will do with various types:</p>
<pre><code class="language-cs">static void ShowNull&lt;T&gt;()
{
    T? val = default;
    Console.WriteLine(val == null);
}

ShowNull&lt;string&gt;();
ShowNull&lt;string?&gt;();
ShowNull&lt;int&gt;();
ShowNull&lt;DateTimeOffset&gt;();
ShowNull&lt;int?&gt;();
ShowNull&lt;DateTimeOffset?&gt;();
</code></pre>
<p>This prints out:</p>
<pre><code>True
True
False
False
True
True
</code></pre>
<p>So this tells us that <code>Max</code> will consider <code>TResult</code> to be potentially nullable if it's a reference type like <code>string</code>, or if it's a nullable value type like <code>int?</code> or <code>DateTimeOffset?</code>. But plain value types like <code>int</code> and <code>DateTimeOffset</code> are considered not to be nullable.</p>
<p>That explains why using the <code>x =&gt; x.Label</code> lambda makes <code>Max</code> return <code>null</code> when the list is empty, while with <code>d =&gt; d.Date</code> or <code>d =&gt; d.Number</code>, it throws an exception. The first has a return type of <code>string</code> (a reference type) while the other two have non-nullable value-typed return types (<code>DateTimeOffset</code> and <code>int</code>).</p>
<p>But why does <code>Max</code> even have these two different code paths? It's perfectly possible for a method to return a <code>DateTimeOffset?</code>, so why does <code>Max</code> not do that here? If the argument for the type parameter <code>TResult</code> is <code>DateTimeOffset</code>, and the method declares a return type of <code>TResult?</code>, shouldn't that make the return type <code>DateTimeOffset?</code>?</p>
<p>The reason it doesn't work out that way is because nullability handling for reference types was a bit of an afterthought in C#. (See my extensive <a href="https://endjin.com/blog/2020/04/dotnet-csharp-8-nullable-references-non-nullable-is-the-new-default">series on nullable reference types for (a lot) more detail</a>.)</p>
<p>In the beginning (C# 1.0) there were value types, which could not be <code>null</code>, and reference types, which were always capable of being <code>null</code>. There simply wasn't any concept of a value type being nullable, and nor was there any way to constrain a reference type to be non-null. This reflected the underlying reality of the .NET runtime's type system. Then C# 2.0 added support for nullable value types, enabling us to write <code>int?</code>. But this was an entirely different way of representing nullability: an <code>int?</code> is really a <code>Nullable&lt;int&gt;</code>, and <code>Nullable&lt;T&gt;</code> essentially combines a value with a <code>bool</code> indicating whether the value is present. This is fundamentally different from how reference types like <code>string</code> represent <code>null</code>. (This is more of a library feature than a runtime feature. OK, strictly speaking there's some special handling for <code>Nullable&lt;T&gt;</code> when it comes to boxing and unboxing, but otherwise, this is mainly a language feature that doesn't directly reflect how the underlying runtime type system really works.) Although C# lets us write code that works with <code>int?</code> in ways that are (sometimes) similar to how we might work with a reference, the generated code is really quite different, and that causes challenges for generic code. And finally, C# 8.0 introduced nullability annotations for reference types, so that now, we write <code>string?</code> if we mean a reference that might be <code>null</code> whereas <code>string</code> is (in theory) never null, in a way that is conceptually similar to the fact that an <code>int</code> can never be null.</p>
<p>But although we've ended up in a place where there are apparently two dimensions—value vs reference, and nullable vs non-nullable—the history of how we got here means these aren't truly independent. Nullability works very differently for values vs references in practice. And two of the four combinations (nullable values, and non-nullable references) aren't really first class citizens in the .NET type system. (A nullable value in a null state looks different from the <code>null</code> reference value. And a 'non-nullable' reference might in fact be <code>null</code>.)</p>
<p>And this difference tends to poke out from time to time with surprising behaviour like we're seeing with this <code>Max</code> operator. It would be completely reasonable to expect it to deal with the <code>Label</code> and <code>Date</code> properties in exactly the same way. But the history of nullability in .NET means it doesn't work in practice.</p>
<p>So how do we fix this? We can use this slightly ugly hack:</p>
<pre><code class="language-cs">DateTimeOffset? maxDate = empty.Max(d =&gt; (DateTimeOffset?)d.Date);
</code></pre>
<p>That cast means that the lambda's type is now <code>Func&lt;WithValues, DateTimeOffset?&gt;</code>. (Before it was <code>Func&lt;WithValues, DateTimeOffset&gt;</code>, with a non-nullable return type.) Since <code>default(DateTimeOffset?) == null</code>, <code>Max</code> will select the code path that returns <code>null</code> when the input collection is empty. (It doesn't do that without this cast, because <code>default(DateTimeOffset)</code> is not <code>null</code>. It's a value representing midnight on the 1st January in the year 1, with a zero time zone offset.)</p>
<p>But what about that specialized (non-generic) overload of <code>Max</code> I linked to earlier that returns an <code>int?</code>? Well it turns out that it only comes into play when the selector also returns an <code>int?</code>. You get a different overload when the selector returns a plain <code>int</code>. So it ends up looking similar to the <code>DateTimeOffset</code> case (which used the generic overload). We need to cast to a nullable value:</p>
<pre><code class="language-cs">int? maxNumber = empty.Max(x =&gt; (int?)x.Number);
</code></pre>
<p>So we can make it work how we want, it's just slightly messy. That's the reality of a 25+ year old language that has made two major changes to the nature of what it means to be <code>null</code>.</p>]]></content:encoded>
    </item>
    <item>
      <title>Returning to Work After a Career Break: How Remote Work Made It Possible</title>
      <description>After years away, I returned to work in the UK. Here's how remote flexibility protected my mental health and made that transition possible!</description>
      <link>https://endjin.com/blog/returning-to-work-after-a-career-break</link>
      <guid isPermaLink="true">https://endjin.com/blog/returning-to-work-after-a-career-break</guid>
      <pubDate>Thu, 16 Apr 2026 05:30:00 GMT</pubDate>
      <category>Remote</category>
      <category>Remote Working</category>
      <category>Wellbeing</category>
      <category>Career</category>
      <category>Digital Nomad</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/04/returning-to-work-after-a-career-break.png" />
      <dc:creator>Carmel Eve</dc:creator>
      <content:encoded><![CDATA[<p>In 2021 I left work, the UK, and most of my friends and family behind. After what felt like half a decade trapped (at points literally) in a small flat in Manchester, I couldn't wait to get out and explore the world. It was an amazing, life-changing experience. But, after some years away, it was time to return home. And, in doing so, I had some decisions to make.</p>
<p>When I first arrived at Gatwick airport I had no idea what life was going to look like - I stayed with a friend, found some temporary work, and tried to reacclimatise to life at home. There were many reunions, tears, and more than one panic attack due to complete overwhelm - reverse culture shock is a real thing, who knew?</p>
<p>But, when all was said and done, though I made some vague attempts to see what was out there in the working world, it felt like there was really only one place I wanted to end up - and that was back at endjin.</p>
<p>I've now been back in the UK nearly two years, and back at work for 18 months, and there is definitely a lot to reflect on.</p>
<h2 id="remote-work-and-digital-nomading">Remote Work and Digital Nomading</h2>
<p>I know "digital nomad" can sound a bit buzzword-y, but it is the simplest label for how I have been living since I got back.</p>
<p>Having spent almost 3 years without staying in one place more than a month, the idea of signing a year-long lease felt terrifying in the extreme. And, endjin being a totally remote company (and having been so since 2018 - before it was cool...), I luckily didn't have to.</p>
<p>A lot of Airbnbs offer discounts for stays over a month, and with the current price of electricity and gas, you can often find options for far less than you'd pay at a standard rental - especially if (like me) you are drawn to places in the middle of nowhere, even in the depths of winter. So, that is what I did...</p>
<p>I lived all over the UK - Devon, Yorkshire, Bristol, North Wales, and even spent the winter in Spain (something that convinced me more than ever that January in the UK just isn't for me). And, in all of this, I learnt a lot about what I value in the places that I live. The feeling of being able to walk out the door into nature is something that, for me, is unparalleled. That being said, somewhere with train connections allowing me to attend the 5+ weddings I need to go to (yes, I am in my 30s) is equally important. Two months spent in a depression fog in a village in Yorkshire taught me that access to a gym or at least <em>some</em> way to exercise when it's raining is also a must for my mental health...</p>
<p>Honestly, I do not think returning to work would have been possible for me at that point without this flexibility. My mental state was not great, and trying to go straight from years of constant movement into a rigid routine would have been too big a shock. Being able to choose where I lived, reduce pressure where I could, and make changes gradually meant I could build back up rather than burn out.</p>
<p>Remote-first work gave me the space to re-enter life in the UK on my own terms, and that flexibility is what has made the first year back at work feel in some ways like a continuation of the adventure.</p>
<h2 id="what-i-want-to-carry-forward">What I Want to Carry Forward</h2>
<p>I know that not everyone has the opportunity, means, or even desire to live month-to-month unpacking and re-packing, but I do think that there are some lessons that I've learnt which are applicable whatever your situation:</p>
<ul>
<li>Over-planning the next 1/3/5 years can make you more anxious, not less. One of the biggest things I had to accept when travelling was that nothing ever goes fully to plan. That's as true for life in general as it is for catching 4 buses in a day. Spending all my time running through every possible scenario and outcome is never as helpful as it feels in the moment. (As someone with anxiety, I know that's easier said than done. And, to be clear, I'm not saying don't plan at all - I'm told <em>some</em> financial planning is probably a good idea...)</li>
<li>You don't need to work everything out at once. Trying something and deciding it doesn't work is far better than never changing at all. Most of the time, all you need to plan is the next step.</li>
<li>Notice what makes you happy. Nothing makes me feel better than being in nature, so building a life around that feels not only logical, but necessary.</li>
<li>Also notice what drains you. For me, winter has always been hard. Once I accepted that, it became much easier to make decisions that were actually good for me.</li>
<li>Revisit your priorities every few months. What mattered to you last year might not be what you need now.</li>
<li>Don't confuse discomfort with failure. Some uncertainty is just part of change, and it does not always mean you've made the wrong decision.</li>
</ul>
<p>And, if you are considering stepping into the world of "digital nomading", some advice:</p>
<ul>
<li>If you are working, make sure that you have enough time to appreciate a place. The first couple of places I stayed, I was only there for a month. By the time I'd moved the first weekend and left the final one, I felt like I had barely found my feet before I was moving again. Plan a good margin between moves - back-to-back travel plus work can be exhausting very quickly.</li>
<li>Think about the practical - what are some of the things that you do every day / every week that you'd struggle without - a swimming pool? A gym? A library? A pub within walking distance..?</li>
<li>How much travel do you need to do whilst you are there? Can you find somewhere you love that doesn't mean spending 5 hours on a train multiple times per month?</li>
<li>Always have a backup internet option. A local SIM/hotspot can save a lot of panic on work days.</li>
<li>Budget for comfort, not just cost. Sometimes paying a bit more for location, heating, or a proper desk is worth it.</li>
<li>Remember that it can be lonely. Moving around means you don't always build a base where you are living. Make sure you know what you will do if you are feeling alone - are there friends nearby? Do you have people you can call? Are there local groups you can get involved in?</li>
</ul>
<h2 id="final-thought">Final thought</h2>
<p>The biggest thing I have learned is that you do not need to blow up your whole life to make things better. You can use the same approach wherever you are: pay attention to what helps, be honest about what drains you, and focus on the next sensible step instead of waiting for a perfect long-term plan. That shift has made life feel less overwhelming, work feel far more sustainable, and I'm excited about building a life that works for me - whatever that might look like!</p>]]></content:encoded>
    </item>
    <item>
      <title>AI-assisted coding is four decisions, not one</title>
      <description>A simple mental model for making sense of the AI-assisted coding landscape: four layers, four decisions.</description>
      <link>https://endjin.com/blog/ai-assisted-coding-is-four-decisions-not-one</link>
      <guid isPermaLink="true">https://endjin.com/blog/ai-assisted-coding-is-four-decisions-not-one</guid>
      <pubDate>Mon, 13 Apr 2026 23:08:00 GMT</pubDate>
      <category>AI</category>
      <category>GenAI</category>
      <category>Claude Code</category>
      <category>GitHub Copilot</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/ai-assisted-coding-is-four-decisions-not-one.png" />
      <dc:creator>Mike Evans-Larah</dc:creator>
      <content:encoded><![CDATA[<p>The pace of change in the world of AI-assisted coding is overwhelming. With new tools, frameworks, and platforms emerging and evolving constantly, it can be hard to keep up, let alone understand how all the pieces fit together.</p>
<p>People often ask questions like "Should I use Cursor or ChatGPT?" or "Is Claude better than Copilot?" but to make decisions about which tools to use (or even ask the right sort of questions), it's important to understand the underlying architecture.</p>
<p>In this post, I want to share a simple mental model that has helped me make sense of the AI-assisted coding landscape.</p>
<h2 id="the-four-layers">The four layers</h2>
<p>At a high level, we can think of AI-assisted coding as being composed of four distinct layers:</p>
<ol>
<li><strong>Harness</strong>: The user interface and experience for interacting with the AI. It includes things like code editors, chat interfaces, CLI tools, and the system prompts that shape the AI's behaviour.</li>
<li><strong>Capabilities</strong>: The tools, instructions, skills, and context sources that extend what the AI can do — increasingly portable across harnesses.</li>
<li><strong>Model</strong>: The AI model that processes input and generates tokens — text, code, images, or other outputs. This is where the "intelligence" lives.</li>
<li><strong>Provider</strong>: The infrastructure and services that host and run the model. This includes cloud platforms, APIs, and the computational resources (GPUs, memory, state) needed to power it.</li>
</ol>
<p>These layers build on each other: the harness provides the interface, capabilities extend what's possible, the model does the reasoning, and the provider supplies the compute.</p>
<h3 id="harness">Harness</h3>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/ai-assisted-coding-harness.png" alt="Harness illustration" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/03/ai-assisted-coding-harness.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/03/ai-assisted-coding-harness.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/03/ai-assisted-coding-harness.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/03/ai-assisted-coding-harness.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>The harness is what you actually interact with day-to-day, and there's a surprisingly wide range of options. Broadly, they fall into a few categories:</p>
<ul>
<li><strong>Chat interfaces</strong>: Web-based or desktop tools like claude.ai or ChatGPT, where you paste code in and get responses back. Great for quick questions and exploration, but limited when it comes to working with full projects.</li>
<li><strong>IDE extensions</strong>: Tools like GitHub Copilot Chat or Roo Code that plug into your existing editor (VS Code, Visual Studio, JetBrains, etc.). These meet you where you already work, with direct access to your codebase.</li>
<li><strong>Purpose-built IDEs</strong>: Editors like Cursor and Google Antigravity that have been built from the ground up with AI at their core (usually forks of VS Code). They offer deep integration between the editor experience and the AI capabilities.</li>
<li><strong>App builders</strong>: Tools like Lovable that focus on generating entire applications from natural language descriptions, targeting less technical users or rapid prototyping.</li>
<li><strong>CLI tools</strong>: Command-line agents like Claude Code, OpenCode, and Copilot CLI that let you work with AI directly from your terminal. These tend to appeal to developers who prefer keyboard-driven workflows.</li>
</ul>
<p>Even within these categories, there has been a push recently towards different user interactions. For example, integrating voice control or continuing conversations across devices.</p>
<p>But the harness isn't just about where you interact with the AI, it's about how the harness shapes the AI's behaviour. Modern harnesses go well beyond simple chat, adding capabilities such as:</p>
<ul>
<li><strong>Agentic workflows</strong>: The ability for the AI to plan, execute multi-step tasks, spawn sub-agents, run commands, and iterate on its own output. This might happen locally in the harness, or it might hand off to cloud-based agents running on the provider layer.</li>
<li><strong>System prompts</strong>: The invisible instructions that shape how the AI behaves. This is a bigger deal than most people realise. The same model can perform dramatically differently depending on the harness it's running in, because each harness ships its own system prompt.</li>
<li><strong>Memory and context</strong>: Persistent memory across sessions, project-level instructions, and the ability to pull in relevant files and documentation automatically.</li>
</ul>
<p>These harness-level capabilities can make a huge difference to your productivity, often more so than the choice of model itself. A great model in a limited harness won't perform as well as a good model in a harness that gives it the right tools and context.</p>
<h3 id="capabilities">Capabilities</h3>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/ai-assisted-coding-capabilities.png" alt="Capabilities illustration" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/03/ai-assisted-coding-capabilities.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/03/ai-assisted-coding-capabilities.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/03/ai-assisted-coding-capabilities.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/03/ai-assisted-coding-capabilities.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>The capabilities layer is what you layer on top of the harness to extend what the AI can do. This has arguably been one of the biggest developments in the last year. It includes:</p>
<ul>
<li><strong>Tools and MCP</strong>: The Model Context Protocol (MCP) has emerged as a standard way to give AI access to external tools — running tests, querying databases, calling APIs, searching the web, interacting with design tools, and more. These tools are increasingly portable: the same MCP server can work across Claude Code, GitHub Copilot, Cursor, and other harnesses.</li>
<li><strong>Instructions and skills</strong>: Project-level instruction files (like <code>.instructions.md</code> or <code>.cursorrules</code>) that tell the AI about your codebase conventions, preferred patterns, and how to approach tasks. Custom skills and agent definitions let you package domain-specific knowledge that the AI can draw on.</li>
<li><strong>Context sources</strong>: Documentation, codebase indexing, knowledge bases, and other reference material that help the AI understand your specific domain and codebase.</li>
</ul>
<p>What makes capabilities a distinct layer (rather than just a feature of the harness) is their portability. You can take the same MCP servers, the same instruction files, and in many cases the same context sources, and use them across different harnesses. Your investment in configuring capabilities isn't locked to a single tool.</p>
<h3 id="model">Model</h3>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/ai-assisted-coding-model.png" alt="Model illustration" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/03/ai-assisted-coding-model.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/03/ai-assisted-coding-model.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/03/ai-assisted-coding-model.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/03/ai-assisted-coding-model.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>The model is where the "thinking" happens. When you send a prompt, it's the model that interprets your intent, reasons about the problem, and generates tokens in response — whether that's code, prose, or increasingly other modalities like images and audio. Models differ across several key dimensions:</p>
<ul>
<li><strong>Reasoning ability</strong>: How well the model can break down complex problems, plan multi-step solutions, and handle nuanced logic. The emergence of dedicated reasoning modes (like "extended thinking") has been a significant step forward here, though higher reasoning levels consume substantially more tokens.</li>
<li><strong>Code generation quality</strong>: The accuracy, correctness, and idiomatic quality of the code it produces across different languages and frameworks.</li>
<li><strong>Tool use</strong>: How reliably the model can decide when and how to call external tools provided by the harness and capabilities layer, and how well it can structure its output to work with those tools.</li>
<li><strong>Context window</strong>: How much text the model can "see" at once. Larger context windows mean the model can work with bigger codebases without losing track of important details.</li>
<li><strong>Speed</strong>: How quickly the model generates responses. For interactive coding, latency matters, so a slower but more capable model isn't always the best choice for every task.</li>
</ul>
<p>Today's landscape includes several categories of model:</p>
<p><strong>Frontier models</strong> - like Claude, GPT, and Gemini - are the most capable, hosted in the cloud, and accessed via API. They're constantly being updated and represent the cutting edge.</p>
<p><strong>Local models</strong> can run on your own hardware. Tools like Ollama or LM Studio make it straightforward to run open-weight models (e.g. Qwen, Llama, DeepSeek). They're typically less capable than frontier models, but they offer advantages in terms of privacy, cost (no per-token charges), and the ability to work offline. It's worth noting that "open-weight" doesn't always mean fully open — you can download and run the model, but you typically don't know how it was trained or on what data.</p>
<p><strong>Specialist models</strong> are smaller, narrowly focused models tuned for specific tasks: speech-to-text (e.g. Whisper), text-to-speech, classification, summarisation, OCR, and more. While frontier models are incredibly powerful, they're also expensive; for high-volume business tasks, smaller and more cost-efficient models often make more sense.</p>
<p>And you don't have to pick just one. Many harnesses let you switch models on the fly, so you can use a fast, lightweight model for simple tasks and a more powerful frontier model when you need heavy reasoning.</p>
<h3 id="provider">Provider</h3>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/ai-assisted-coding-provider.png" alt="Provider illustration" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/03/ai-assisted-coding-provider.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/03/ai-assisted-coding-provider.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/03/ai-assisted-coding-provider.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/03/ai-assisted-coding-provider.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>The provider layer is often invisible to individual developers, but it's where many of the most important enterprise concerns live. When an organisation is evaluating AI coding tools, the questions at this layer tend to dominate the conversation:</p>
<ul>
<li><strong>Data residency</strong>: Where are your prompts and context being sent, and where are they processed? For regulated industries, data staying within a specific geographic region can be a hard requirement.</li>
<li><strong>Security and compliance</strong>: Does the provider meet the organisation's security standards? Are prompts and code snippets logged or used for training? What certifications does the provider hold (SOC 2, ISO 27001, etc.)?</li>
<li><strong>Copyright and IP</strong>: There are risks associated with generating code using models trained on public data, or agents retrieving proprietary information. Some providers offer guarantees around IP ownership and indemnity (such as Microsoft's Copilot Copyright Commitment), which can be crucial for commercial use.</li>
<li><strong>Rate limits and availability</strong>: How many requests can you make before hitting throttling? Is there an SLA for uptime? For a team of developers relying on AI throughout the day, rate limits can become a real bottleneck.</li>
<li><strong>Cost management</strong>: Pricing varies significantly, from flat-rate subscriptions to per-token usage billing. At scale, understanding and controlling costs becomes critical. This is compounded by the cost of reasoning: enabling higher reasoning levels on capable models can dramatically increase token consumption.</li>
</ul>
<p>This is where options like <strong>Microsoft Foundry</strong>, and <strong>Amazon Bedrock</strong> come in. They let enterprises access frontier models through their existing cloud provider (though not always within the same infrastructure), with the governance, networking, and compliance controls they already have in place. You get your own dedicated capacity, and billing flows through your existing agreements. Model marketplaces like <strong>Hugging Face</strong> also play a role, providing a catalogue of models (both open-weight and commercial) that can be deployed on your own infrastructure.</p>
<p>For most individual developers, the provider layer is something you don't think about much — it just works. But for teams and organisations adopting AI coding tools at scale, it's often the layer that determines which tools are actually allowed to be used.</p>
<h3 id="bundled-vs.mix-and-match">Bundled vs. mix-and-match</h3>
<p>In practice, you'll see these layers packaged together in different ways. Some products bundle all layers tightly, while others give you the freedom to pick and choose.</p>
<p>For example, a <strong>Claude Pro subscription</strong> bundles everything: the claude.ai chat interface, Claude desktop app, and Claude Code CLI (harness), Anthropic's Claude models (model), and Anthropic's own infrastructure (provider). It's a clean, simple experience — but you're largely locked into Anthropic's choices at every layer.</p>
<p><strong>GitHub Copilot</strong> takes a more flexible approach. You get VS Code / Visual Studio IDE extensions (or Copilot CLI) as your harness, but you can choose from a wide selection of models: Claude, GPT, Gemini, and others. The models are hosted on different providers behind the scenes, but this is abstracted away. You can also bring your own capabilities via MCP servers and instruction files. You can even use alternative harnesses like the Claude SDK from within the Copilot ecosystem, or connect local models.</p>
<p>Then there are tools like <strong>OpenCode</strong> or <strong>Roo Code</strong>, which are open-source harnesses that let you bring your own model <em>and</em> your own provider. You could run a local model on your own hardware, connect to an API key with OpenAI, or point it at an Azure OpenAI deployment your team manages. This gives maximum flexibility, but you're responsible for wiring it all up.</p>
<h3 id="blurring-boundaries">Blurring boundaries</h3>
<p>It's worth noting that the boundaries between these layers are starting to blur. Agentic capabilities that used to live purely in the harness are increasingly being pushed into the model and provider layers. Code can run on your local device, in the cloud, or as a fleet of sub-agents. And you can orchestrate all of it from your IDE, a terminal, or even a mobile device. The mental model is still useful for making decisions, but the sharp lines between layers are softening as the ecosystem matures.</p>
<h2 id="conclusion">Conclusion</h2>
<p>This post isn't intended to recommend a specific tool or combination - what's right for you will depend on your constraints, your team's needs, and the kind of work you're doing. But by understanding that there are four distinct decision points - harness, capabilities, model, and provider - and the trade-offs at each layer, you can make informed choices rather than getting lost in the noise. When someone asks "Should I use Cursor or Claude?", you'll know that's not quite the right question, and you'll know what questions to ask instead.</p>]]></content:encoded>
    </item>
    <item>
      <title>Integration Testing Azure Functions Part 5: Reqnroll in Build Pipeline</title>
      <description>Integration testing Azure Functions with Reqnroll and C#. Part 5 covers running your Corvus.Testing specs in Azure DevOps and GitHub Actions pipelines.</description>
      <link>https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-5-using-corvus-testing-reqnroll-in-a-build-pipeline</link>
      <guid isPermaLink="true">https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-5-using-corvus-testing-reqnroll-in-a-build-pipeline</guid>
      <pubDate>Sat, 11 Apr 2026 06:35:00 GMT</pubDate>
      <category>Azure</category>
      <category>Azure Functions</category>
      <category>BDD</category>
      <category>Corvus</category>
      <category>Corvus.Testing.ReqnRoll</category>
      <category>Corvus.Testing</category>
      <category>Durable Functions</category>
      <category>Reqnroll</category>
      <category>Testing</category>
      <category>Integration Testing</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>Visual Studio</category>
      <category>Gherkin</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/integration-testing-azure-functions-with-reqnroll-and-csharp-part-5-using-corvus-testing-reqnroll-in-a-build-pipeline.png" />
      <dc:creator>Jonathan George</dc:creator>
      <content:encoded><![CDATA[<p><strong>TL;DR</strong> - This series of posts shows how you can integration test Azure Functions projects using the open-source <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll">Corvus.Testing.AzureFunctions.ReqnRoll</a> library and walks through the different ways you can use it in your Reqnroll projects to start and stop function app instances for your scenarios and features.</p>
<p>In the previous posts in this series, we introduced the Corvus.Testing.AzureFunctions.ReqnRoll project and showed how you can use the bindings and classes it provides to start functions apps as part of your scenarios and features. We're going to finish with some pointers on how to ensure these functions can run as part of your build pipelines.</p>
<p>Depending on how your build system works, it's relatively easy to ensure that tests using these methods are able to run as part of the build pipeline.</p>
<p>If, like us, you're using Azure DevOps with hosted agents, you'll need to add a step to your pipeline to install the Azure Functions Core Tools. For YAML build definitions, it looks like this:</p>
<pre><code class="language-YAML">- task: Npm@1
  displayName: 'Install Azure Functions V4 Core Tools'
  inputs:
    command: custom
    verbose: false
    customCommand: 'install -g azure-functions-core-tools@4 --unsafe-perm true'
</code></pre>
<p>Once that step has run, the test will be able to execute as it does locally. The <code>Corvus.Testing.AzureFunctions.ReqnRoll</code> library targets .NET 8, which is cross-platform, so this should work on both Windows and Linux build agents.</p>
<p>GitHub Actions is similarly straightforward. Simply add a step to install the Azure Functions Core Tools towards the start of your pipeline.</p>
<p>This approach should also work for other CI servers and their hosted build agents. If you're using a private agent you have the option of installing the tools globally, meaning your build scripts can just assume they are present - this very much depends on how you prefer to manage build agents.</p>
<h2 id="summary">Summary</h2>
<p>For Reqnroll users, the techniques I've shown in these posts will help ensure your integration tests are more complete by ensuring that functions under test are hosted in a way that closely matches the Azure environment they will ultimately run in.</p>
<p>I've tried to keep the posts simple by only covering testing HTTP triggered functions, but these techniques can equally be used to test functions that use other trigger types too. In our projects we've used it to test functions with blob, queue and Event Hubs endpoints, as well as functions using Durable extensions. This will generally require other infrastructure for your integration testing - for example, <a href="https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azurite">Azurite</a> and/or <a href="https://testcontainers.com/">Testcontainers</a> to make storage available to trigger blob and queue endpoints - but the principles remain the same.</p>
<p>As mentioned above, the Corvus.Testing projects (<a href="https://github.com/corvus-dotnet/Corvus.Testing.ReqnRoll">Corvus.Testing.ReqnRoll</a> and <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll">Corvus.Testing.AzureFunctions.ReqnRoll</a>) are open source and contributions are accepted. If you encounter problems with them, please feel free to raise an Issue - and if you're able, submit a pull request. And if you have any questions or feedback, just ask!</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Integration Testing Azure Functions with Reqnroll &amp; C#</h3>
        <span class="series-toc__count">5 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-1-introduction" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Introduction</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-2-using-step-bindings-to-start-functions" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Using Step Bindings to Start Functions</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-3-using-hooks-to-start-functions" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Using Hooks to Start Functions</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-4-controlling-your-functions-with-additional-configuration" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Controlling Functions with Configuration</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">5.</span>
                <span class="series-toc__part-title">Using Corvus.Testing.ReqnRoll in a Build Pipeline</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Integration Testing Azure Functions Part 4: Reqnroll Configuration</title>
      <description>Integration testing Azure Functions with Reqnroll and C#. Part 4 shows how to supply or override configuration values for the functions apps under test.</description>
      <link>https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-4-controlling-your-functions-with-additional-configuration</link>
      <guid isPermaLink="true">https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-4-controlling-your-functions-with-additional-configuration</guid>
      <pubDate>Sat, 11 Apr 2026 06:34:00 GMT</pubDate>
      <category>Azure</category>
      <category>Azure Functions</category>
      <category>BDD</category>
      <category>Corvus</category>
      <category>Corvus.Testing.ReqnRoll</category>
      <category>Corvus.Testing</category>
      <category>Durable Functions</category>
      <category>Reqnroll</category>
      <category>Testing</category>
      <category>Integration Testing</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>Visual Studio</category>
      <category>Gherkin</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/integration-testing-azure-functions-with-reqnroll-and-csharp-part-4-controlling-your-functions-with-additional-configuration.png" />
      <dc:creator>Jonathan George</dc:creator>
      <content:encoded><![CDATA[<p><strong>TL;DR</strong> - This series of posts shows how you can integration test Azure Functions projects using the open-source <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll">Corvus.Testing.AzureFunctions.ReqnRoll</a> library and walks through the different ways you can use it in your Reqnroll projects to start and stop function app instances for your scenarios and features.</p>
<p>In the previous posts in this series, we introduced the Corvus.Testing.AzureFunctions.ReqnRoll project and showed how you can use the bindings and classes it provides to start functions apps as part of your scenarios and features. Here, we look at how you can vary the behaviour of those functions apps by providing or overriding configuration values.</p>
<p>When the <code>FunctionsController</code> is used to start a new function, it will check the <code>ScenarioContext</code> (if available) and <code>FeatureContext</code> for an instance of the <code>FunctionConfiguration</code> class. Any configuration provided here will be made available to the functions app when it starts.</p>
<p>If you're using step bindings to start your function, you can do this by writing an additional step binding to provide configuration from wherever you need to retrieve it. Note that this step must come before the one that starts the function. You can see an example in <code>ScenariosUsingStepBindings.feature</code>:</p>
<p>If you're using a <code>BeforeScenario</code> or <code>BeforeFeature</code> hook, you can add the configuration at the same time - as shown in <code>ScenariosUsingPerScenarioHookWithAdditionalConfiguration.feature</code> (and the corresponding hook method in <code>DemoFunctionPerScenario</code>, <code>StartFunctionWithAdditionalConfigurationAsync</code>), as well as the per-feature equivalents.</p>
<p>If you need to use different configuration at different times, you can create separate hook methods for setting up configuration - just ensure you use the <code>Order</code> parameter on the hook attributes to ensure the configuration is set prior to the functions being started.</p>
<a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-5-using-corvus-testing-reqnroll-in-a-build-pipeline">In the next (and final) post in the series, we'll cover how to ensure the tests you've written using the techniques covered in these posts can run in your build pipelines.</a>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Integration Testing Azure Functions with Reqnroll &amp; C#</h3>
        <span class="series-toc__count">5 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-1-introduction" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Introduction</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-2-using-step-bindings-to-start-functions" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Using Step Bindings to Start Functions</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-3-using-hooks-to-start-functions" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Using Hooks to Start Functions</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">4.</span>
                <span class="series-toc__part-title">Controlling Functions with Configuration</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-5-using-corvus-testing-reqnroll-in-a-build-pipeline" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Using Corvus.Testing.ReqnRoll in a Build Pipeline</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Integration Testing Azure Functions Part 3: Reqnroll hooks</title>
      <description>Integration testing Azure Functions with Reqnroll and C#. Part 3 uses scenario and feature hooks to start functions apps and keep your BDD specs readable.</description>
      <link>https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-3-using-hooks-to-start-functions</link>
      <guid isPermaLink="true">https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-3-using-hooks-to-start-functions</guid>
      <pubDate>Sat, 11 Apr 2026 06:33:00 GMT</pubDate>
      <category>Azure</category>
      <category>Azure Functions</category>
      <category>BDD</category>
      <category>Corvus</category>
      <category>Corvus.Testing.ReqnRoll</category>
      <category>Corvus.Testing</category>
      <category>Durable Functions</category>
      <category>Reqnroll</category>
      <category>Testing</category>
      <category>Integration Testing</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>Visual Studio</category>
      <category>Gherkin</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/integration-testing-azure-functions-with-reqnroll-and-csharp-part-3-using-hooks-to-start-functions.png" />
      <dc:creator>Jonathan George</dc:creator>
      <content:encoded><![CDATA[<p><strong>TL;DR</strong> - This series of posts shows how you can integration test Azure Functions projects using the open-source <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll">Corvus.Testing.AzureFunctions.ReqnRoll</a> library and walks through the different ways you can use it in your Reqnroll projects to start and stop function app instances for your scenarios and features.</p>
<p>In the first two posts in this series, <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-1-introduction">we introduced the Corvus.Testing.AzureFunctions.ReqnRoll project</a> and <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-2-using-step-bindings-to-start-functions">showed how you can use the Reqnroll step bindings it provides to start functions apps</a> as part of your scenarios. This approach has the drawback of making your scenarios harder to read for non-technical users, so in this post, we're going to take a look at using scenario and feature hooks to address that problem.</p>
<h2 id="using-per-scenario-hooks-to-start-and-stop-functions-as-shown-in-scenariosusingperscenariohook.feature">Using per-scenario hooks to start and stop functions - as shown in <code>ScenariosUsingPerScenarioHook.feature</code></h2>
<p>Reqnroll hooks allow us to add code that's executed at specific points during a test run. With this method, we make use of the <code>BeforeScenario</code> and <code>AfterScenario</code> hooks, and use the <code>Corvus.Testing.AzureFunctions.FunctionsController</code> class directly to start and stop our functions. This can be seen in the <code>DemoFunctionPerScenario</code> class:</p>
<p>The parameters that the <code>StartFunctionsInstance</code> method takes are the same as those shown in the step binding above, allowing you to specify project, port and runtime. You'll see that the created <code>FunctionsController</code> instance is stored in the <code>ScenarioContext</code>; this allows us to pull it out in the <code>AfterScenario</code> method (which you should add yourself, as shown in the test code) to tear down the functions.</p>
<h3 id="advantages-to-this-method">Advantages to this method</h3>
<p>Using this method conceals the technical detail of what the setup step involves, reducing it to a single tag for the function. This makes your scenarios much more readable. If you're writing lots of tests for a specific functions app, it also reduces the duplication needed when every scenario has to contain the setup step.</p>
<p>In addition, test output (and the associated functions output) can be viewed in exactly the same way as above.</p>
<h3 id="disadvantages-to-this-method">Disadvantages to this method</h3>
<p>The main disadvantage to this approach is one that's associated with pretty much all integration testing: speed. Whilst setting up and tearing down all dependencies for each test is the gold standard, spinning up functions takes time and this can mean your test suite takes a long time to execute. In some scenarios this may be unavoidable. However, others may lend themselves to using the third method to strike a balance between speeding up execution and isolating tests.</p>
<h2 id="using-per-feature-hooks-to-start-and-stop-functions-as-shown-in-scenariosusingperfeaturehook.feature">Using per-feature hooks to start and stop functions - as shown in <code>ScenariosUsingPerFeatureHook.feature</code></h2>
<p>Visually, this approach looks extremely similar to the previous method. The scenario definitions are not affected at all and the only differences in the underlying code (other than using <code>BeforeFeature</code> and <code>AfterFeature</code> attributes) being that the <code>FeatureContext</code> is used in place of the <code>ScenarioContext</code> to store and retrieve the <code>FunctionsController</code>. The other difference is that the hook methods themselves need to be static to be used with per-feature hooks - this is a Reqnroll requirement.</p>
<h3 id="advantages-to-this-method-1">Advantages to this method</h3>
<p>If you can group related scenarios and be sure they won't conflict with one another, this is a relatively easy way of speeding up test execution.</p>
<h3 id="disadvantages-to-this-method-1">Disadvantages to this method</h3>
<p>As implied above, this approach does have the potential to cause unexpected results if your tests conflict with one another in any way. The other disadvantage is that because the function output is gathered and written to console when the function is terminated, it can no longer be seen in the test output. If you don't mind duplication, you can get round this by adding an additional <code>AfterScenario</code> hook to write the function output to the console after every scenario.</p>
<a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-4-controlling-your-functions-with-additional-configuration">In the next post, we'll show how you can provide additional configuration to functions apps started as part of tests.</a>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Integration Testing Azure Functions with Reqnroll &amp; C#</h3>
        <span class="series-toc__count">5 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-1-introduction" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Introduction</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-2-using-step-bindings-to-start-functions" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Using Step Bindings to Start Functions</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">3.</span>
                <span class="series-toc__part-title">Using Hooks to Start Functions</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-4-controlling-your-functions-with-additional-configuration" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Controlling Functions with Configuration</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-5-using-corvus-testing-reqnroll-in-a-build-pipeline" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Using Corvus.Testing.ReqnRoll in a Build Pipeline</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Integration Testing Azure Functions Part 2: Reqnroll step bindings</title>
      <description>Integration testing Azure Functions with Reqnroll and C#. Part 2 uses Corvus.Testing step bindings to start and stop functions apps in your scenarios.</description>
      <link>https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-2-using-step-bindings-to-start-functions</link>
      <guid isPermaLink="true">https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-2-using-step-bindings-to-start-functions</guid>
      <pubDate>Sat, 11 Apr 2026 06:32:00 GMT</pubDate>
      <category>Azure</category>
      <category>Azure Functions</category>
      <category>BDD</category>
      <category>Corvus</category>
      <category>Corvus.Testing.ReqnRoll</category>
      <category>Corvus.Testing</category>
      <category>Durable Functions</category>
      <category>Reqnroll</category>
      <category>Testing</category>
      <category>Integration Testing</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>Visual Studio</category>
      <category>Gherkin</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/integration-testing-azure-functions-with-reqnroll-and-csharp-part-2-using-step-bindings-to-start-functions.png" />
      <dc:creator>Jonathan George</dc:creator>
      <content:encoded><![CDATA[<p><strong>TL;DR</strong> - This series of posts shows how you can integration test Azure Functions projects using the open-source <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll">Corvus.Testing.AzureFunctions.ReqnRoll</a> library and walks through the different ways you can use it in your Reqnroll projects to start and stop function app instances for your scenarios and features.</p>
<p>In the <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-1-introduction">first post in this series</a>, we introduced the <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll">Corvus.Testing.AzureFunctions.ReqnRoll</a> library. In this post, we're going to take a look at the simplest way of using it to start functions apps for testing purposes, which is to use the provided step bindings.</p>
<p>This is demonstrated in <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll/tree/main/Solutions/Corvus.Testing.AzureFunctions.ReqnRoll.Demo.Specs/AzureFunctionsTesting/ScenariosUsingStepBindings.feature"><code>ScenariosUsingStepBindings.feature</code></a> in the <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll/tree/main/Solutions/Corvus.Testing.AzureFunctions.ReqnRoll.Demo.Specs">Corvus.Testing.AzureFunctions.ReqnRoll.Demo.Specs</a> project.</p>
<p>The Corvus.Testing.AzureFunctions.ReqnRoll project contains a <code>Given</code> step definition for the following pattern:</p>
<pre><code class="language-gherkin">[Given("I start a functions instance for the local project '(.*)' on port (.*) with runtime '(.*)'")]
</code></pre>
<p>If you include steps that match this pattern in your scenario, they will cause the functions defined in the specified project to be run, with HTTP functions listening on the specified port. If your function doesn't actually have any HTTP endpoints you can supply a dummy value for the port. Runtime will most likely be <code>net8.0</code> (for Functions v4), though <code>net9.0</code> and <code>net10.0</code> are also options depending on your target framework.</p>
<p>The project to run is currently resolved by traversing up the folder tree until it finds a folder that, when combined with the function name, runtime and build folder, provides a valid path.</p>
<p>As well as bindings for these steps, there's an additional AfterScenario hook that goes with them to tear down the functions instances they start. You can start multiple functions in a single scenario using these bindings if necessary.</p>
<h3 id="viewing-function-output">Viewing function output</h3>
<p>Once the test run is complete, output from the functions app can be seen in the Test Detail Summary. In Visual Studio, this is visible in the Test Explorer by selecting the scenario that's been executed and clicking the scenario that's been selected:</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2020/03/Test-explorer.png" alt="Test Explorer" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2020/03/Test-explorer.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2020/03/Test-explorer.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2020/03/Test-explorer.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2020/03/Test-explorer.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>Clicking the link "Open additional output for this result" will show Reqnroll's standard output capture:</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2020/03/Test-output.png" alt="Test output window" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2020/03/Test-output.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2020/03/Test-output.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2020/03/Test-output.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2020/03/Test-output.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>As you can see from the screenshot above, this starts with the output from the <code>BeforeScenario</code> binding showing the solution and runtime location. If starting the function failed for some reason, you'd most likely see the reason here.</p>
<p>This is followed by Reqnroll's standard per-step output. Finally the output from the <code>AfterScenario</code> binding is shown, which is where the StdOut and StdErr for each function is added.</p>
<p>Note that the log shown in this window is frequently a truncated version of the whole. If this is the case, you'll see a message explaining how to access the full log by copying and pasting into another tool.</p>
<h3 id="advantages-to-this-method">Advantages to this method</h3>
<p>Using step bindings in this way makes it crystal clear to the developer what's going on as part of their spec. You can easily see which functions are being run and on what ports.</p>
<h3 id="disadvantages-to-this-method">Disadvantages to this method</h3>
<p>Whilst it's nice for developers to see exactly what technical setup is taking place, this does go against the goals of Behaviour Driven Development. Specifically, we should be striving to make the feature readable in the end user's language. When testing an API using a BDD spec, you can make a case that the end user whose language we should be using is a technical one - the consumers of APIs are most likely to be developers - but even so, this is an overly technical step to include in your scenarios.</p>
<a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-3-using-hooks-to-start-functions">In the next post, I'll show how this problem can be addressed using Reqnroll hooks to start and stop functions apps.</a>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Integration Testing Azure Functions with Reqnroll &amp; C#</h3>
        <span class="series-toc__count">5 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-1-introduction" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Introduction</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">2.</span>
                <span class="series-toc__part-title">Using Step Bindings to Start Functions</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-3-using-hooks-to-start-functions" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Using Hooks to Start Functions</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-4-controlling-your-functions-with-additional-configuration" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Controlling Functions with Configuration</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-5-using-corvus-testing-reqnroll-in-a-build-pipeline" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Using Corvus.Testing.ReqnRoll in a Build Pipeline</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Integration Testing Azure Functions with Reqnroll Part 1: Introduction</title>
      <description>Integration testing Azure Functions with Reqnroll and C#. Part 1 sets out the testing challenge and introduces the open-source Corvus.Testing library.</description>
      <link>https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-1-introduction</link>
      <guid isPermaLink="true">https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-1-introduction</guid>
      <pubDate>Sat, 11 Apr 2026 06:31:00 GMT</pubDate>
      <category>Azure</category>
      <category>Azure Functions</category>
      <category>BDD</category>
      <category>Corvus</category>
      <category>Corvus.Testing.ReqnRoll</category>
      <category>Corvus.Testing</category>
      <category>Durable Functions</category>
      <category>Reqnroll</category>
      <category>Testing</category>
      <category>Integration Testing</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>Visual Studio</category>
      <category>Gherkin</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/03/integration-testing-azure-functions-with-reqnroll-and-csharp-part-1-introduction.png" />
      <dc:creator>Jonathan George</dc:creator>
      <content:encoded><![CDATA[<p><strong>TL;DR</strong> - This series of posts shows how you can integration test Azure Functions projects using the open-source <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll">Corvus.Testing.AzureFunctions.ReqnRoll</a> library and walks through the different ways you can use it in your Reqnroll projects to start and stop function app instances for your scenarios and features.</p>
<p>If you use Azure Functions on a regular basis, you'll likely have grappled with the challenge of testing them. The testing story for functions is not hugely well defined. If you're building your functions well, then there won't be a lot of code in them - they will be thin facades calling into code that does the bulk of the work, in which case you will likely have used a standard unit testing approach on that code. Nevertheless, that likely leaves some functionality untested - for example, ensuring your models are correctly bound to input, and ensuring that correct status codes, headers and so on are returned from your requests.</p>
<p>As such, it becomes necessary to step up a level and look at how to test the functions as a whole. There are two options for this:</p>
<ul>
<li>You can test in-process, using <a href="https://techcommunity.microsoft.com/blog/fasttrackforazureblog/azure-functions---part-2---unit-and-integration-testing/3769764">the approach defined in Microsoft's docs</a> (note that this doesn't seem to have been updated to take account of functions that use instance methods, but that's unlikely to affect the approach). This is good, but doesn't ensure that your function is configured correctly, and if you're using automatic model binding, it doesn't test that this is working as you expect.</li>
<li>Alternatively, you can test out of process, either against a deployed instance of the function or against one that's running locally. Testing against a deployed instance is a great idea, but this is normally reserved for another level of testing, meant to ensure that things are working as expected in a deployed environment. It doesn't address the needs of the developer as the feedback loop from making a change to deploying a function to Azure is likely just too long. This leaves us with the challenge of testing against a function running locally.</li>
</ul>
<p>So, how do we go about this?</p>
<p>Before I continue I should note that while I'm specifically addressing how to do this with <a href="https://reqnroll.net/">Reqnroll</a>, a very similar approach can be taken with other frameworks. Reqnroll is the community-driven successor to SpecFlow, created by the original SpecFlow creator after SpecFlow reached end-of-life. If you're migrating from SpecFlow, the <a href="https://docs.reqnroll.net/latest/guides/migrating-from-specflow.html">Reqnroll migration guide</a> is a great place to start.</p>
<h2 id="goals">Goals</h2>
<p>As always, it's worth starting with what we want to achieve:</p>
<ol>
<li>We want a way of automatically starting a function, and then shutting it down once the test is completed.</li>
<li>We want this to work in as close a way as possible to a deployed function</li>
<li>Ideally, we want to be able to capture the output from the function while it's running.</li>
<li>It's useful to be able to easily affect the configuration of the function under test.</li>
<li>We want an approach that can work as part of a CI pipeline.</li>
</ol>
<p>So, let's have a look at how we achieve these goals.</p>
<h2 id="running-the-function-locally">Running the function locally</h2>
<p>When you hit F5 to run a function in Visual Studio, it uses a copy of the Azure Functions Core Tools that's managed by Visual Studio. Normally they get automatically installed into <code>C:\Users\username\AppData\Local\AzureFunctionsTools\Releases</code> and Visual Studio selects the correct version to use based on your project's runtime.</p>
<p>However, this is an internal detail of how Visual Studio implements the Functions SDK, so it's not really something we can rely on. Fortunately <a href="https://docs.microsoft.com/en-us/azure/azure-functions/functions-run-local">you can install and use Azure Functions Core Tools directly</a>.</p>
<p>We recommend using Azure Functions v4 with the isolated worker model and .NET 8 or later. The isolated worker model is the recommended approach for new Azure Functions projects, and in-process support is scheduled to end in November 2026.</p>
<p>To get the tools installed, you have a few choices. If you're on Windows, you can use winget:</p>
<pre><code class="language-bash">winget install Microsoft.Azure.FunctionsCoreTools
</code></pre>
<p>or Chocolatey</p>
<pre><code class="language-bash">choco install azure-functions-core-tools
</code></pre>
<p>Otherwise, you'll need npm:</p>
<pre><code class="language-bash">npm i -g azure-functions-core-tools@4 --unsafe-perm true
</code></pre>
<p>This will install the tools locally - you can verify they are there using the new <code>func</code> command from the command prompt. If you do this, you'll see all the things you can do with it - scaffolding new functions apps and functions, and running functions locally. The latter is what we're concerned with - you'll see that you can start a new function using the command <code>func start</code>, providing port number and other details as part of the command. This is what we're going to use when setting up our test.</p>
<h2 id="introducing-corvus.testing">Introducing Corvus.Testing</h2>
<p>The code to start, stop and manage functions as part of a Reqnroll test is part of the endjin-sponsored Corvus.Testing libraries. The original <a href="https://github.com/corvus-dotnet/Corvus.Testing">Corvus.Testing</a> repository has been split into separate, focused repos:</p>
<ul>
<li><a href="https://github.com/corvus-dotnet/Corvus.Testing.ReqnRoll">Corvus.Testing.ReqnRoll</a> — general Reqnroll testing utilities</li>
<li><a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll">Corvus.Testing.AzureFunctions.ReqnRoll</a> — Azure Functions-specific testing classes and bindings</li>
</ul>
<p>The classes that we're interested in are part of <code>Corvus.Testing.AzureFunctions.ReqnRoll</code> and are:</p>
<p><a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll/blob/main/Solutions/Corvus.Testing.AzureFunctions/Corvus/Testing/AzureFunctions/FunctionsController.cs"><strong>FunctionsController.cs</strong></a> - this contains methods to start a new functions instance, and to tear down all functions it manages. It's intended to live for the lifetime of the test as it captures the output and error streams from the function and write them all to the Console when the functions are terminated. When running in Reqnroll, this results in that information being written to the test's output.</p>
<p><a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll/blob/main/Solutions/Corvus.Testing.AzureFunctions/Corvus/Testing/AzureFunctions/FunctionConfiguration.cs"><strong>FunctionConfiguration.cs</strong></a> - this is part of the mechanism by which the test project can provide settings to the function under test.</p>
<p><a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll/blob/main/Solutions/Corvus.Testing.AzureFunctions.ReqnRoll/Corvus/Testing/AzureFunctions/ReqnRoll/FunctionsBindings.cs"><strong>FunctionsBindings.cs</strong></a> - this provides a couple of standard step bindings that can be used as part of a scenario to start a function.</p>
<p>This code is all open source, and contributions are accepted. It's available under the Apache 2.0 open source license meaning you're free to use and modify the code as you see fit. The license does impose some conditions around retaining copyright attributions and so on - <a href="https://www.apache.org/licenses/LICENSE-2.0">you can read the full details here</a>.</p>
<p>This code ticks the boxes for the first four of the five goals I set out above, providing mechanisms to keep functions running for the duration of test execution, as well as a way to supply additional configuration. The next few sections explain the different ways of using this.</p>
<p>I'll be doing this with reference to the demo projects that are part of the Corvus.Testing.AzureFunctions.ReqnRoll codebase. Before continuing, I recommend downloading the project so you can examine the code. There are two demo functions projects — <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll/tree/main/Solutions/Corvus.Testing.AzureFunctions.Demo.InProcess">Corvus.Testing.AzureFunctions.Demo.InProcess</a> for the in-process model and <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll/tree/main/Solutions/Corvus.Testing.AzureFunctions.Demo.Isolated">Corvus.Testing.AzureFunctions.Demo.Isolated</a> for the isolated worker model — that contain a slightly modified version of code that's generated when you create a new HTTP-triggered function in Visual Studio. They accept GET and POST requests, looking for a parameter called <code>name</code> in either the querystring or request body, and returning a configurable string containing that parameter.</p>
<p>It also contains a Reqnroll test project, <a href="https://github.com/corvus-dotnet/Corvus.Testing.AzureFunctions.ReqnRoll/tree/main/Solutions/Corvus.Testing.AzureFunctions.ReqnRoll.Demo.Specs">Corvus.Testing.AzureFunctions.ReqnRoll.Demo.Specs</a> containing feature files which relate to the following next few posts in this series.</p>
<a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-2-using-step-bindings-to-start-functions">In the next post, I'll show you how you can add steps to your Reqnroll scenarios to run your functions apps.</a>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Integration Testing Azure Functions with Reqnroll &amp; C#</h3>
        <span class="series-toc__count">5 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">1.</span>
                <span class="series-toc__part-title">Introduction</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-2-using-step-bindings-to-start-functions" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Using Step Bindings to Start Functions</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-3-using-hooks-to-start-functions" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Using Hooks to Start Functions</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-4-controlling-your-functions-with-additional-configuration" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Controlling Functions with Configuration</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/integration-testing-azure-functions-with-reqnroll-and-csharp-part-5-using-corvus-testing-reqnroll-in-a-build-pipeline" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">Using Corvus.Testing.ReqnRoll in a Build Pipeline</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>From Prompt Engineering to AI Programming: Enterprise GenAI Solutions</title>
      <description>Shift from prompt engineering to AI programming by applying rigorous software engineering principles to your LLM integrations.</description>
      <link>https://endjin.com/blog/programming-not-prompting</link>
      <guid isPermaLink="true">https://endjin.com/blog/programming-not-prompting</guid>
      <pubDate>Fri, 13 Mar 2026 05:30:00 GMT</pubDate>
      <category>GenAI</category>
      <category>Generative AI</category>
      <category>AI</category>
      <category>Machine Learning</category>
      <category>Software Engineering</category>
      <category>Engineering Discipline</category>
      <category>LLM</category>
      <category>Prompt Engineering</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/02/programming-not-prompting.png" />
      <dc:creator>James Broome</dc:creator>
      <content:encoded><![CDATA[<p>As organizations race to integrate generative AI into their business workflows, they are hitting a familiar challenge - the gap between a cool demo and a reliable enterprise solution (one that you'd be confident betting the business, or at the very least your reputation on). To bridge this, we must shift the mindset from prompt engineering to rigorous AI programming and systematic evaluation, just like with any other software engineering project.</p>
<h2 id="the-quality-challenge-in-the-age-of-ai">The quality challenge in the age of AI</h2>
<p>Over the last year or so we have seen the world move at 100mph, with AI integration into every product (whether it's a good fit or not!) and organisations eager to deploy LLMs and AI services into production in their own systems and workflows. With frontier models readily available through cloud APIs, and deep enterprise integration into big data platforms through wrapper/portal services like <a href="https://azure.microsoft.com/en-us/products/ai-foundry">Microsoft Foundry</a>, it's very easy to get started and get excited about what's possible.</p>
<p>This is exacerbated by the fact that AI is now pretty much ubiquitous to all consumers of digital products. If business stakeholders are used to being able to get results instantly from native LLM app interfaces on their devices, then expectations are high right from the offset.</p>
<p>But there's a big gap between a PoC and a working, reliable enterprise solution. On the face of it, adding an LLM service to your application is just another API integration, but their behaviour brings with it a new set of engineering concerns that can cause problems if not understood fully.</p>
<p>This post explains what those things are, and why they should be treated the same as any other engineering quality concern, so that you can build AI-integrated solutions with confidence.</p>
<h2 id="a-timeline-of-engineering-quality">A timeline of engineering quality</h2>
<p>Before we dig into the specifics of LLM models and AI services, it's useful to look back at other technology and software architecture patterns, and understand how we thought about them in terms of ensuring quality. The easiest way for me to do that is to look back at my own career. Clearly this won't be a fully comprehensive guide, but there's enough experience in there to highlight commonalities across technology stacks and ecosystems.</p>
<h3 id="establishing-the-foundations">Establishing the foundations</h3>
<p>When I began working as a software developer in 2001, after a brief dabble with Borland Delphi and ASP pages, I quickly found myself immersed in the world of .NET. This was .NET 1.1 territory and <a href="https://en.wikipedia.org/wiki/ASP.NET_Web_Forms">ASP.NET Web Forms</a> (around which there were a lot of strong opinions!). As a framework that was designed to magically generate HTML web pages using a set of server-side components, it was inherently difficult to pull apart business logic from the user interface layer, which made it hard to test.</p>
<p>But what we were building was complicated (<a href="https://en.wikipedia.org/wiki/Transport_Direct_Portal">a government funded, UK-wide, multi-model journey planner, pre-Google Maps</a>) and we needed to prove that it was working correctly. The "auto-magic" that the framework provided allowed for rapid development of things that were simple, but started to get in the way and make things harder as the logic and interactions became more complex. I learned about unit testing as these tools started to become available for .NET (NUnit, MbUnit and then MSTest), and how to refactor code to be able to validate the things we needed to. Having confidence that things were working as they should shifted the dial from slow and brittle feedback loops to rapid, reliable validation cycles.</p>
<p>By the time the ALT.NET movement gained traction towards the end of the 2000's, I was a full-blown <a href="https://en.wikipedia.org/wiki/Test-driven_development">TDD</a> aficionado. Shifting the focus to test-first made for better system design, and along with it came more advanced techniques and tooling like mocking, inversion-of-control containers and continuous integration processes to automate quality gates.</p>
<p>Around this time, I was also lucky enough to attend one of JP Boodhoo's .NET engineering bootcamps in Vancouver, which embedded the value of executable specifications with <a href="https://en.wikipedia.org/wiki/Behavior-driven_development">Behaviour Driven Development (BDD)</a>, highlighting the importance of natural language and encouraging closer collaboration between business and technical teams.</p>
<p>These core principles have been the foundation underpinning all software development I've been involved in since, enabling high-quality delivery of well documented code in iterative development cycles. They've been applied across web and native application stacks and across a variety of architecture patterns. But whilst there's been specific implementations and frameworks that became the flavour of the month according to the language or toolset of the moment, it's the concepts and approach that have always been the thing.</p>
<h3 id="contract-first-observability-and-resilience-in-the-cloud">Contract-first, observability and resilience in the cloud</h3>
<p>Fast forward to the 2010s and the world had moved on to API-first, REST-based architectures. At this point I was leading a small team responsible for building the payment processing engine for a large Middle-Eastern airline. With these APIs any mistakes could literally cost money, so as well as ensuring that we had comprehensive test coverage, we also focused on instrumentation and observability to help us diagnose things in our production environment. This was how I learned (the hard way!) that <a href="https://en.wikipedia.org/wiki/ISO_4217#Minor_unit_fractions">not all currencies have 2 decimal places</a>! And more generally that if you're building an API, you also need a way to execute it. This was pre-Postman, and pre-Swagger, so the only thing to do was build our own version of an API client - a test harness that could be used to execute and validate the various endpoints.</p>
<p>In parallel to this, we were moving everything into the cloud, and started to encountered a new set of quality challenges. Distributed systems brought transient failures, eventual consistency, and the need for sophisticated retry logic.</p>
<p>This experience taught me that integration testing isn't just about verifying that your code works, it's about understanding the contract between systems, the innate behavioural patterns and capabilities of those systems, and building evaluation harnesses that can systematically validate behaviour across different scenarios.</p>
<h3 id="data-machine-learning-and-the-challenge-of-uncertainty">Data, machine learning, and the challenge of uncertainty</h3>
<p>By the time we get to 2015, the cloud had also allowed us to capture a lot more data. And so the landscape shifted again with an increasing focus on data engineering - machine learning, data science, cloud data platforms and advanced analytics. The data space was, and in many ways still is, less mature when it comes to engineering practices. But whilst the challenges were different, I found myself still applying the same "there's always a way to test something" mentality.</p>
<p>With machine learning and data science, the tendency to draw conclusions from patterns in data that are really just random noise could be balanced with <a href="https://endjin.com/blog/machine-learning-the-process-is-the-science">structured experimentation, upfront definition of success metrics and rigorous validation</a>.</p>
<p>Cloud data pipelines that were asynchronous and long-running meant you couldn't just run a quick unit test and get instant feedback. So we needed new approaches - schema validation to <a href="https://endjin.com/blog/creating-quality-gates-in-the-medallion-architecture-with-pandera">catch structural changes and data quality issues early</a>, <a href="https://endjin.com/what-we-think/talks/fake-it-til-you-make-it-generating-production-quality-test-data-at-scale">synthetic data generation</a> to test edge cases that might not appear in production for months and data snapshot testing to validate consistency of pipeline outputs over time. We developed approaches for testing data quality at scale, validating not just that pipelines completed successfully, but that the data they produced was fit for purpose.</p>
<p>The key insight from this is that uncertainty doesn't mean untestable, it just means we need different validation strategies. This sometimes meant shifting from testing specific values to testing behaviours and patterns. My <a href="https://endjin.com/what-we-think/talks/how-to-ensure-quality-and-avoid-inaccuracies-in-your-data-insights">talk at SQL Bits in 2024 - "Do those numbers look right?"</a> summarises how we were thinking about engineering quality in our data solutions, and deep dives into practical approaches for testing Power BI reports, data pipelines and Spark and Python interactive notebooks.</p>
<h2 id="so-are-llms-really-any-different">So are LLMs really any different?</h2>
<p>Yes and no. On the one hand, they're just another integration - either via an API, or through local model deployment. But on the other hand, they exhibit characteristics that require us to think differently about validation.</p>
<p><strong>They're non-deterministic</strong>: Unlike traditional APIs where the same input always produces the same output, LLMs can generate different responses each time due to their probabilistic nature (even when you set the temperature to 0). This makes traditional unit testing approaches which rely on exact output matching ineffective.</p>
<p><strong>They can hallucinate</strong>: LLMs can confidently generate plausible-sounding but false information. Unlike a database query that either returns valid data or throws an error, an LLM might return a well-constructed response that is actually wrong - syntactically correct, but semantically and factually incorrect. This requires us to validate not just the structure of responses, but their factual accuracy and relevance.</p>
<p><strong>They produce qualitative, unstructured responses</strong>: Traditional software returns structured data (JSON objects, numerical values, boolean flags etc.). LLMs return natural language, which is inherently ambiguous and context-dependent. Despite advancements in the area of structured outputs, this still isn't 100% reliable. And how do you write an assertion that validates "the response should be friendly and helpful"?</p>
<p>However, none of these challenges are entirely new. Non-determinism shows up in async operations, race conditions, rate limiting, or time-dependent behaviour. User experience validation can be qualitative in nature. And we've had to deal with integration points that might return unexpected results.</p>
<blockquote>
<p>On that basis we can, and should, apply the same core engineering principles we've always used, albeit adapted to the unique characteristics of LLMs.</p>
</blockquote>
<h2 id="break-open-the-black-box">1. Break open the black box</h2>
<p>Just as ASP.NET Web Forms made it hard to test by tightly coupling UI and logic, LLM integrations can become black boxes if we treat them as magic, closed systems. The solution is the same - refactor to separate concerns. This means treating your LLM interaction as a discrete component with clear inputs and outputs. Which are the configurable bits that you have control over (e.g. the model version, the prompt, the temperature etc.), and which bits are "inside the box" (e.g. the inner workings of the model, system prompts etc.)?</p>
<p>For example, don't embed prompts directly in your application code. Instead, create a prompt management layer that allows you to version, test, and iterate on prompts independently of your application logic.</p>
<p>At endjin, when we build LLM-powered solutions, we structure them so that the prompt construction, model invocation, and response parsing are separate, testable components. This helps to unlock the ability to swap models, adjust parameters, or refine prompts without touching core business logic.</p>
<h2 id="embrace-natural-language-as-a-feature">2. Embrace natural language as a feature</h2>
<p>One of the biggest insights from Behaviour Driven Development was that natural language specifications bridge the gap between business stakeholders and technical teams. LLMs flip this on its head - natural language isn't just the specification, it's also the programming interface.</p>
<p>This could be seen of as an advantage. You can write evaluation criteria in plain English: "<em>The response should identify the customer's primary concern</em>", "<em>The summary should be under 100 words</em>", "<em>The sentiment should be appropriate to the context</em>". Then you can use LLMs themselves to evaluate these criteria. Don't forget that the LLM can also be instructed to return additional numerical or categorical information that can augment the natural language response (for example a confidence level between 0 and 1), which can enable more traditional testing to still be performed.</p>
<p>Taking it a step further, part of the specification could be a feedback loop to improve the specification (akin to getting someone else to review your work) before you execute the steps. This might mean LLMs all the way down, but in a good way.</p>
<p>The key is to be systematic about it. There's possible weaknesses around this approach when you consider the vagaries of language, but creating a feedback loop to explore the context with an LLM can be very powerful. Define your success criteria upfront, create evaluation prompts that assess those criteria, and validate them against labelled examples before you rely on them in production.</p>
<h2 id="test-first-even-for-prompts">3. Test-first, even for prompts</h2>
<p>The discipline of Test-Driven Development teaches us to think about desired outcomes before implementation. This is even more important with LLMs, where it's easy to iterate endlessly on prompts without a clear definition of success.</p>
<p>Start by defining your test cases - specific inputs with expected behaviours. Not exact outputs (remember, we're dealing with non-deterministic responses) but behavioural expectations. For a customer service chatbot, you might want to identify what a complaint is about and make sure the right resolution is offered.</p>
<p>For example:</p>
<pre><code class="language-gherkin">Given the customer comment is "Despite paying extra for speedy postage, the promised delivery date was missed by 3 days!"
When the chatbot generates a customer service response
Then the response should identify the core issue as 'delivery delay'
And the tone should be 'apologetic'
And the resolution offered should be 'refund of premium postage'
</code></pre>
<p>Then iterate on your prompt design until your system reliably meets these criteria. Track your success rate over time. If you're getting 85% success on your test suite, that's a quantifiable baseline you can work to improve. This is infinitely better than the "it seems to work pretty well" approach.</p>
<h2 id="build-or-use-evaluation-harnesses">4. Build or use evaluation harnesses</h2>
<p>Just as we built and used custom API clients to test our APIs, we need evaluation harnesses for LLM integrations. The good news here is that lots of <a href="https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/develop/cloud-evaluation?view=foundry&amp;tabs=python">AI developer services have eval frameworks built in</a>. These aren't just test scripts, they're tools that allow you to systematically evaluate performance across multiple dimensions.</p>
<p>Your evaluation harness should:</p>
<ul>
<li>Run your prompts against a diverse set of test inputs</li>
<li>Capture and version the outputs for comparison</li>
<li>Apply multiple evaluation metrics (accuracy, relevance, tone, safety)</li>
<li>Track performance over time as you refine prompts, change models or a <a href="https://www.anthropic.com/engineering/a-postmortem-of-three-recent-issues">model version is incremented</a></li>
<li>Handle transient failures and rate limits gracefully</li>
</ul>
<p>There's comparisons here with machine learning models that are trained / used in Data Science experiments. Whilst you can't do input / output testing, you can test acceptable tolerance ranges, error rates etc.</p>
<h2 id="you-need-labelled-datasets">5. You need labelled datasets</h2>
<p>Just as you can't train a machine learning model without labelled data, you can't validate an LLM integration without example inputs and expected behaviours. Even a small amout of well-chosen examples can provide meaningful validation. But also focus on edge cases and failure modes. What happens when the input is ambiguous? When it's in a different language? When it contains unusual formatting or special characters?</p>
<p>As your system matures, invest in building a larger, more diverse evaluation dataset. Involve domain experts that can set the acceptance criteria of the system. This becomes your regression test suite, allowing you to confidently refine prompts or change models while ensuring you haven't broken existing functionality. Given the volume of tests that will be required, don't underestimate the the level of effort required to curate them. This contributes to the TCO of the solution - production grade LLM solutions are expensive endeavours.</p>
<h2 id="embrace-transient-failures-and-build-resilience">6. Embrace transient failures and build resilience</h2>
<p>LLM APIs, like any external service, can experience transient failures, rate limits, performance degradation or varying response times. Your integration needs to handle these gracefully.</p>
<p>Implement retry logic with exponential backoff. Cache responses where appropriate. Monitor latency and error rates. Build fallback strategies for when the LLM service is unavailable. These are the same patterns we use for any cloud API integration , just applied to a different integration point.</p>
<h2 id="test-behaviours-not-values">7. Test behaviours, not values</h2>
<p>Finally, apply the same patterns for validating data quality - focus on behaviours and patterns rather than specific values. Instead of asserting that a generated email contains the exact phrase "<em>Thank you for your inquiry</em>", check that it:</p>
<ul>
<li>Addresses the customer's stated concern</li>
<li>Maintains an appropriate tone</li>
<li>Includes relevant next steps</li>
<li>Doesn't contain factual inaccuracies</li>
</ul>
<p>This is more robust to the natural variation in LLM outputs, while still catching the failure modes that matter. It's often beneficial to use a separate LLM model or service to do this validation so that you avoid the innate bias used in generating the original output being used to validate it (i.e. don't ask an LLM to mark its own work). This technique is referred to as LLM-as-a-judge.</p>
<h2 id="instrument-and-observe">8. Instrument and observe</h2>
<p>Across all of these approaches, there's a common thread - you need visibility into what's happening. As more work is handed to the LLM, this accentuates the need for human oversight, supervision, quality checks, curation of more examples, responding proactively to new situations (e.g. a new type of enquiry driven by external factors). Instrument your LLM integrations thoroughly. Log inputs, outputs, and evaluation metrics. Track latency, error rates, and cost. Monitor for drift in model behaviour over time.</p>
<p>At endjin, we treat observability as a first-class concern for LLM integrations, just as we do for any other production system.</p>
<h2 id="summary-from-black-box-to-engineering-discipline">Summary: From black box to engineering discipline</h2>
<p>The journey from prompt engineering to AI programming is really a journey from treating LLMs as magic to treating them as engineered components. Yes, they have unique characteristics (non-determinism, hallucinations, qualitative outputs), but these aren't deal-breakers, they're just new constraints that require adapted approaches.</p>
<p>To succeed in deploying reliable, enterprise-grade AI solutions, treat LLM integrations with the same engineering standards that you apply to any other system component. That means systematic evaluation, defensive engineering, instrumentation, and continuous improvement.</p>
<p>At endjin, we've seen this transformation happen with our clients. The shift from "let's see if this prompt works" to "here's our evaluation framework and current performance metrics" represents a fundamental change in how teams think about AI integration. And it's that shift that enables moving from demos to production-ready solutions with confidence.</p>
<p>In subsequent posts, I'll dive deeper into the practical tools and frameworks that make this systematic evaluation possible.</p>]]></content:encoded>
    </item>
    <item>
      <title>Scaling API Ingestion with the Queue-of-Work Pattern</title>
      <description>The queue-of-work pattern enables massive parallelism for API ingestion by breaking large jobs into thousands of independent work items processed by concurrent workers. This approach reduced data ingestion time for our use case from 15 hours to under 2 hours while providing automatic retry handling and fault tolerance at a fraction of the cost of traditional orchestration tools.</description>
      <link>https://endjin.com/blog/scaling-api-ingestion-with-the-queue-of-work-pattern</link>
      <guid isPermaLink="true">https://endjin.com/blog/scaling-api-ingestion-with-the-queue-of-work-pattern</guid>
      <pubDate>Fri, 06 Mar 2026 06:30:00 GMT</pubDate>
      <category>python</category>
      <category>engineering</category>
      <category>data engineering</category>
      <category>pyspark</category>
      <category>synapse</category>
      <category>notebooks</category>
      <category>azure container apps</category>
      <category>azure synapse analytics</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/02/scaling-api-ingestion-with-the-queue-of-work-pattern.png" />
      <dc:creator>Jonathan George</dc:creator>
      <content:encoded><![CDATA[<p>TL;DR; The queue-of-work pattern enables massive parallelism for HTTP based API ingestion by breaking large jobs into thousands of independent work items processed by concurrent workers. This approach reduced our data ingestion time from 15 hours to under 2 hours while providing automatic retry handling and fault tolerance at a fraction of the cost of traditional orchestration tools.</p>
<p>Sample code to go with this blog post <a href="https://github.com/endjin/python-queue-of-work-pattern-demo">can be found in GitHub</a></p>
<h2 id="the-problem-when-sequential-api-calls-take-days">The Problem: When Sequential API Calls Take Days</h2>
<p>It's common in data platforms to need to acquire data from HTTP based APIs. If written well, these APIs will be reliable, fast and will allow you to control filtering, windowing, and pagination. But even if all of this is true, ingesting large amounts of data via an API can be challenging.</p>
<p>We recently faced one of these challenges when building out a new workload on an existing Azure Synapse-based modern data platform: the initial load of data from a source system required us to ingest about 2,000,000 records and the only way of doing this was via an HTTP API. A full synchronization requires tens of thousands of individual API requests, each fetching a page of 100 records of data.</p>
<p>We started by estimating how long this would take with a simple sequential approach. The API averages about 500ms per request. For 20,000 API calls:</p>
<pre><code class="language-text">20,000 requests × 0.5 seconds = 10,000 seconds = 2.8 hours
</code></pre>
<p>We also need to consider the volumes of data being processed. For us, the payload size is generally from 200KB to 1.5MB, with an average of 1MB. So 20,0000 requests yields around 20 GB of data.</p>
<p>That seems manageable, right? But this calculation ignores several real-world factors:</p>
<ul>
<li><strong>Network variability</strong>: Some requests take 2-3 seconds, especially during peak hours</li>
<li><strong>Failures and retries</strong>: Network issues and transient API errors require retry logic</li>
<li><strong>Data processing</strong>: Each response needs parsing, transformation, and storage</li>
</ul>
<p>In reality, a test retrieval of a subset of records suggested we were looking at something in the region of 48 hours to do a full ingestion, accounting for failures and retries.</p>
<p>That's too long. Our requirements were clear:</p>
<ul>
<li><strong>Scalable</strong>: Process massive volumes efficiently through parallelism</li>
<li><strong>Fault-tolerant</strong>: Handle API failures gracefully without losing progress</li>
<li><strong>Fast</strong>: Complete full ingestion in under 12 hours</li>
</ul>
<h2 id="why-not-use-synapse-pipelines">Why Not Use Synapse Pipelines?</h2>
<p>The customer in this scenario already had a well established Azure Synapse platform in place. As a result the first port of call for data ingestion is normally a Synapse pipeline. After all, Synapse is designed for data orchestration, has many built in connectors and is already fully integrated with our data lake. However, we quickly discovered several dealbreakers for this specific use case:</p>
<h3 id="cost-inefficiency">Cost Inefficiency</h3>
<p>Synapse pipelines charge per activity execution (approximately £0.00085 per activity run) and per integration runtime hour. Let's break down the cost for our 40,000 API calls:</p>
<pre><code class="language-text">20,000 activities × £0.00085 = £17 per full ingestion
Integration Runtime: ~£0.18/hour × 48 hours = £8.64
Total: ~£26 per full sync
</code></pre>
<p>While £26 doesn't sound expensive, issues outside our control mean it's likely we'd need to do this several times a year. We'll also be running nightly incremental updates; although these process way smaller amounts of data, there will occasionally be bulk updates in the source system that require larger data volumes to be reingested.</p>
<p>Over a year, this all adds up. But more importantly, Synapse's orchestration overhead makes it unsuitable for high-volume, small operations regardless of cost.</p>
<h3 id="limited-resilience">Limited Resilience</h3>
<p>Synapse pipelines support retry logic, but their error handling is coarse-grained. If one API call in a batch of 1,000 fails, the retry mechanism repeats the entire batch, not just the failed item. There's no built-in concept of a "poison message queue" for persistently failing records.</p>
<p>When we tested this with a deliberately flaky API endpoint, a single bad record caused the same batch to retry indefinitely, blocking progress on thousands of good records. This simply won't work for production ingestion where some records may have data quality issues.</p>
<h3 id="orchestration-overhead">Orchestration Overhead</h3>
<p>Synapse pipelines introduce latency between activity transitions. In our testing, even with an empty pipeline activity, there's typically 3-5 seconds of overhead per activity execution. For 20,000 activities:</p>
<pre><code class="language-text">20,000 activities × 4 seconds overhead = 80,000 seconds = 22 hours
</code></pre>
<p>This overhead alone exceeds our entire time budget. The pipeline execution model is optimized for long-running data transformations, not for coordinating thousands of quick API calls.</p>
<h3 id="lack-of-dynamic-scaling">Lack of Dynamic Scaling</h3>
<p>Scaling Synapse pipeline execution requires manual configuration of integration runtime settings. You can't easily spin up hundreds of concurrent workers dynamically based on queue depth, then scale down to zero when work completes. This inflexibility makes it difficult to optimize both cost and performance.</p>
<h2 id="the-queue-of-work-pattern-a-better-approach">The Queue-of-Work Pattern: A Better Approach</h2>
<p>After ruling out Synapse pipelines, we needed a solution that could handle parallelism without orchestration overhead. The queue-of-work pattern emerged as the ideal approach, and it's surprisingly simple in concept: break the large job into thousands of small, independent work items, put them in a queue, and let multiple workers process them concurrently.</p>
<p>The pattern decouples work distribution from work execution, which turns out to be the key to solving all our challenges. Here's how it works:</p>
<h3 id="pattern-overview">Pattern Overview</h3>
<pre><code class="language-text">┌─────────────────┐
│   Ingestor      │  1. Breaks large job into small work items
│   (Enqueuer)    │  2. Enqueues items to Azure Storage Queue
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  Azure Storage  │  3. Durable, distributed queue
│     Queue       │  4. Supports automatic retry &amp; poison handling
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│     Queue       │  5. Dequeues messages
│   Processor     │  6. Dispatches to work item processors
│   (Workers)     │  7. Processes API calls
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│   Data Lake     │  8. Writes results
│   Storage       │
└─────────────────┘
</code></pre>
<h3 id="key-components">Key Components</h3>
<ol>
<li><p><strong>Work Items</strong>: Strongly-typed dataclasses that represent individual units of work (e.g., "fetch assets with IDs 1000-5999")</p>
</li>
<li><p><strong>Work Queue</strong>: An abstraction over Azure Storage Queues that handles serialization, dequeuing, and poison message management. Using an abstraction makes testing simpler.</p>
</li>
<li><p><strong>Work Item Processors</strong>: Decorated functions that execute the actual work (API calls, data transformation, persistence)</p>
</li>
<li><p><strong>Queue Processor</strong>: A loop that dequeues messages, dispatches them to the appropriate processor, and handles errors</p>
</li>
<li><p><strong>Work Item Dispatcher</strong>: A registry that maps work item types to their processors using decorator-based registration</p>
</li>
</ol>
<h2 id="how-the-pattern-solves-our-challenges">How the Pattern Solves Our Challenges</h2>
<h3 id="scalability-through-parallelism">1. Scalability Through Parallelism</h3>
<p>The first benefit of this pattern became apparent when we broke down the ingestion workload. Instead of processing 1 million assets sequentially, we could divide them into manageable chunks:</p>
<pre><code class="language-python"># Breaking down 1 million assets into 200 independent work items
max_asset_id = 1_000_000
batch_size = 5_000

for start_id in range(0, max_asset_id, batch_size):
    work_queue.enqueue(
        IngestItemsByIdWorkItem(
            snapshot_time=timestamp,
            correlation_id=correlation_id,
            from_id=start_id,
            to_id=start_id + batch_size - 1
        )
    )
</code></pre>
<p>This creates 200 independent work items, each responsible for 5,000 assets. Why 5,000? We tested different batch sizes and found this offered the best balance - large enough to amortize queue overhead, small enough that a single failure doesn't waste too much work.</p>
<p>Now here's where it gets interesting. With these work items in the queue, we can run multiple queue processors simultaneously:</p>
<ul>
<li>Each processor dequeues and processes messages independently</li>
<li>No coordination overhead between workers - they don't even know about each other</li>
<li>Azure Storage Queues handle concurrent access automatically with optimistic concurrency</li>
<li>Scale up the number of workers simply by deploying more instances</li>
</ul>
<p>In our production environment, we run this as Azure Container Apps Jobs. We did some testing to find out what level of concurrency can be supported by the target API - sending too many concurrent requests would overload the API and cause failures to spike. As a result of this, during a full ingestion we typically scale to 12 concurrent workers. The math works out well:</p>
<pre><code class="language-text">20,000 API calls ÷ 12 workers = 1,666 API calls per worker
1,666 calls × 0.5 seconds average = 833 seconds = 14 minutes
</code></pre>
<p>In practice, we see full ingestion complete in under 2 hours due to API variability and processing overhead, but this is still a massive improvement over the roughly 48 hours sequential approach.</p>
<h3 id="fault-tolerance-through-retry-and-poison-queues">2. Fault Tolerance Through Retry and Poison Queues</h3>
<p>One of the most challenging aspects of large-scale API ingestion is handling failures gracefully. APIs are unreliable - they timeout, return 500 errors, get rate-limited, or occasionally return malformed data. We needed the system to handle these failures without manual intervention.</p>
<p>Azure Storage Queues provide built-in retry semantics through visibility timeouts and dequeue counts. Here's how we leveraged them:</p>
<pre><code class="language-python">class AzureStorageWorkQueue:
    def __init__(self, queue, poison_queue, max_dequeues=5):
        self._queue = queue
        self._poison_queue = poison_queue
        self._max_dequeues = max_dequeues

    def dequeue(self):
        # Visibility timeout of 300 seconds (5 minutes)
        message = self._queue.receive_message(visibility_timeout=300)

        # Automatic poison message handling
        if message and message.dequeue_count &gt; self._max_dequeues:
            # This message has failed 5+ times, move it to poison queue
            self._poison_queue.send_message(message.content)
            self._queue.delete_message(message)
            return None  # Skip this problematic message

        return DequeuedWorkItem(
            work_item=jsonpickle.decode(message.content),
            dequeued_message=message
        )
</code></pre>
<p>Note - to keep the code simpler here, I haven't shown any additional error handling logic around the calls to the storage API. Depending on the exact logic and control mechanisms you have an place, you should consider adding error handling and retry logic around API calls like <code>queue.receive_message</code>.</p>
<p><strong>How this works in practice:</strong></p>
<p>When a worker dequeues a message, it becomes invisible to other workers for 5 minutes. If the worker successfully processes it, the message is deleted. If the worker crashes or the API call fails, the message automatically becomes visible again after 5 minutes for another worker to retry.</p>
<p>Azure Storage Queues track how many times each message has been dequeued. After 5 failed attempts, we consider the message "poisoned". This is a standard term in message processing systems that refers to a message which has repeatedly failed to be dispatched or processed, likely due to bad data or a systematic issue. We don't want to waste resources by continually trying to process the message, but we don't want to lose it either so we move it to a separate "poison queue" for manual investigation.</p>
<p>It should be noted here that even if you've chosen a finite TTL for your message queue, you should ensure that messages on the poison queue never expire by setting their TTL to -1.</p>
<p>This approach has proven remarkably resilient, and it's unusual to see runs end with messages on the poison queue. We've added a check at the end of processing to raise an alert if this does happen so that the bad messages can be investigated. Options also exist inside Azure to monitor message queues for other scenarios - e.g. an excessive number of messages in either queue - and raise alerts.</p>
<p>This approach also aids recovery in the event of a catastrophic failure. For example, during one ingestion run, the source API had a 6-hour outage. This meant that the majority of the queue messages ended up on the poison queue. Once we realised what had happened, we were able to quickly move all of the messages from the poison queue back to the processing queue and rerun them.</p>
<h3 id="speed-through-asynchronous-processing">3. Speed Through Asynchronous Processing</h3>
<p>The queue processor implementation is deliberately simple, which turns out to be a performance advantage:</p>
<pre><code class="language-python">def process_queue(correlation_id, work_queue, logger, ...):
    while not queue_empty:
        message = work_queue.dequeue()
        if message is None:
            queue_empty = True
            continue

        try:
            # Dispatch to appropriate processor
            WorkItemDispatcher.dispatch_work_item(
                message.work_item,
                logger,
                **kwargs
            )
            # Success: remove from queue
            work_queue.remove(message)
        except Exception as e:
            # Failure: message stays in queue for retry
            logger.error(f"Error processing: {e}")
</code></pre>
<p>This simple loop runs on each worker container. There's no complex orchestration, no distributed locking, no coordination between workers. Each worker independently:</p>
<ol>
<li>Dequeues a message (blocking call, returns when message available)</li>
<li>Processes it</li>
<li>Deletes the message on success</li>
<li>Repeats until the queue is empty</li>
</ol>
<p>We measured the queue overhead itself - dequeue plus delete operations - at consistently under 50ms. This means for a typical 500ms API call, only 10% of the time is queue overhead. For slower API calls (2-3 seconds), the overhead becomes negligible.</p>
<p>The pattern also enables progressive completion. Unlike batch systems where you wait hours to see any results, data appears in the data lake as soon as each work item completes. This was particularly valuable during testing - we could verify the data pipeline was working correctly within minutes rather than waiting for a full ingestion to complete.</p>
<h3 id="clean-separation-of-concerns">4. Clean Separation of Concerns</h3>
<p>The dispatcher pattern keeps code maintainable:</p>
<pre><code class="language-python"># Define a work item type
@dataclass
class IngestItemsByIdWorkItem(WorkItem):
    from_id: int
    to_id: int

# Register its processor with a decorator
@work_item_processor(IngestItemsByIdWorkItem)
def process_items_by_id(work_item, logger, **kwargs):
    api_client = kwargs["api_client"]
    writer = kwargs["writer"]

    # Fetch data from API
    response = api_client.get_items(
        build_query_params(work_item.from_id, work_item.to_id)
    )

    # Persist to data lake
    writer.persist_assets(work_item.snapshot_time, response)
</code></pre>
<p>Benefits:</p>
<ul>
<li>Each processor focuses on one specific task</li>
<li>Easy to add new work item types without modifying existing code</li>
<li>Type safety through Python dataclasses</li>
<li>Testable in isolation</li>
<li>Clear dependency injection through kwargs</li>
</ul>
<h2 id="important-considerations">Important Considerations</h2>
<p>Before implementing this pattern, there are several important factors to consider:</p>
<p><strong>Data must be able to be partitioned</strong>: Since all the work is enumerated up front, you need a way of partitioning your data into a large number of small chunks. For our full ingestion, we chose ID ranges and for our incremental updates we chose time periods. This will be driven in part by the querying options supported by the API.</p>
<p><strong>Idempotency is critical</strong>: Since messages can be retried automatically, your processors must be idempotent. If a worker crashes after persisting data but before deleting the message, another worker will process the same message again. In our implementation, we write data to the data lake with consistent file paths based on the work item parameters, so re-processing simply overwrites the existing file with identical data.</p>
<p><strong>Message size limits</strong>: Azure Storage Queue messages are limited to 64 KB. Our work items are small (typically &lt;1 KB when serialized), but if you need to pass large payloads, consider storing the data in blob storage and passing a reference in the message.</p>
<p><strong>Visibility timeout tuning</strong>: The 5-minute visibility timeout works well for our API calls, but you'll need to adjust this based on your workload. Too short and messages might be retried while still being processed (duplicate work). Too long and failed messages won't be retried quickly enough.</p>
<p><strong>Cost considerations</strong>: While Azure Storage Queue costs are minimal (~£0.00028 per 10,000 operations), container runtime costs can add up. With 50 workers running for 2 hours:</p>
<pre><code class="language-text">12 workers × 2 hours × £0.0001/vCPU-second × 0.5 vCPU × 3600 seconds
= approximately £4.32 per full ingestion
</code></pre>
<p>This is cheaper than the Synapse cost but provides much better performance and flexibility.</p>
<p><strong>Avoid sensitive data in messages</strong>: Work items should contain only metadata (IDs, timestamps, pagination offsets), not actual sensitive data. The actual data from API responses goes directly to the data lake, not through the queue.</p>
<h2 id="real-world-implementation-example">Real-World Implementation Example</h2>
<p>Let's walk through a complete flow showing how these components work together for ingesting data:</p>
<h3 id="step-1-enqueue-work-main-orchestrator">Step 1: Enqueue Work (Main Orchestrator)</h3>
<p>The first step runs in a dedicated "enqueuer" container that breaks the full ingestion job into work items:</p>
<pre><code class="language-python">class BronzeIngestor:
    def enqueue_full_ingestion(self, snapshot_time, correlation_id):
        # First, query the API to determine the scope of work
        # This makes a single API call to get the maximum asset ID
        max_asset_id = self._get_maximum_asset_id()

        # Break into batches of 5,000 assets each
        batch_size = 5_000
        current_id = 0

        while current_id &lt; max_asset_id:
            self._work_queue.enqueue(
                IngestItemsByIdWorkItem(
                    snapshot_time=snapshot_time,
                    correlation_id=correlation_id,
                    from_id=current_id,
                    to_id=current_id + batch_size - 1
                )
            )
            current_id += batch_size
</code></pre>
<p>This enqueuing process typically completes in under a 30 seconds for 20,000 work items. The <code>snapshot_time</code> ensures all workers use the same timestamp for file paths, making the data lake files consistent. The <code>correlation_id</code> ties all work items to the same ingestion job for tracing.</p>
<p>Depending on how many items you end up needing to enqueue, and what else lives in your queue, you will also need to consider error and recovery scenarios here. What if you fail half way through enqueuing work items? Whilst the items themselves need to be idempotent, we don't want to put multiple of the same item on the queue if we can avoid it.</p>
<p>If you have one queue per process, then the simplest option will be to ensure the queue is empty before you start processing, and have a recovery process which clears messages down in case of error.</p>
<h3 id="step-2-process-queue-worker-containers">Step 2: Process Queue (Worker Containers)</h3>
<p>Multiple worker containers run simultaneously, each executing the same code but processing different messages:</p>
<pre><code class="language-python">class BronzeIngestor:
    def process_queue(self, correlation_id):
        # Set up dependencies that processors will need
        processor_kwargs = {
            "api_client": self._api_client,
            "writer": self._bronze_writer
        }

        # This runs until the queue is empty
        # Each worker container runs this independently
        process_queue(
            correlation_id=correlation_id,
            work_queue=self._work_queue,
            logger=self._logger,
            get_processor_kwargs=lambda msg: processor_kwargs
        )
</code></pre>
<p>The <code>process_queue</code> function (shown in the "Speed" section above) is just a simple loop. When the queue is empty, the worker exits gracefully.</p>
<h3 id="step-3-execute-work-registered-processor">Step 3: Execute Work (Registered Processor)</h3>
<p>The dispatcher routes each work item to its registered processor. The <code>@work_item_processor</code> decorator registers this function to handle <code>IngestItemsByIdWorkItem</code> instances:</p>
<pre><code class="language-python">@work_item_processor(IngestItemsByIdWorkItem)
def process_items_by_id(work_item, logger, **kwargs):
    api_client = kwargs["api_client"]
    writer = kwargs["writer"]

    # Each work item handles a range of IDs (e.g., 1000-5999)
    # But the API paginates responses, so we need an inner loop
    offset = 0
    limit = 100  # API returns 100 assets per page

    while True:
        # Make API call for one page of results within this ID range
        response = api_client.get_items(
            build_query_params(
                work_item.from_id,
                work_item.to_id,
                offset,
                limit
            )
        )

        assets = json.loads(response)["assets"]
        if not assets:
            break  # No more results, we're done with this work item

        # Write this page to the data lake
        # File path includes snapshot_time for consistency across workers
        writer.persist_assets(
            work_item.snapshot_time,
            work_item.from_id,
            work_item.to_id,
            offset,
            response
        )

        offset += limit
</code></pre>
<p>For a work item covering IDs 1000-5999 (5,000 assets), this typically makes 50 API calls (100 assets per page). The entire work item takes about 25 seconds to process, which fits comfortably within the 5-minute visibility timeout.</p>
<h3 id="step-4-experimentation-and-tuning">Step 4: Experimentation and tuning</h3>
<p>Once you have a working implementation, then some experimentation is needed to validate and tune batch sizes and the amount of parallelism you can support.</p>
<p>When considering batch sizes, there are a variety of factors to take account of. Firstly, how many API requests will be required to ingest a single batch? As mentioned above, our 5,000 asset batches result in around 50 API calls.</p>
<p>If any one of them fails, they will all be retried. If you can't rely on the API to consistently ingest an entire batch of data without failure, you should tune your batch size to minimise the impact of this.</p>
<p>Also, how much data will the API return in a single batch? If you need to process an entire batch of data at once (even if it requires multiple API calls to retrieve), and your payload size is large, will you have enough memory to process it all?</p>
<p>How long will a single batch take to process? This has an effect on your queue visibility timeout; if your batch takes 5 minutes to process but your queue visibility timeout is 2 minutes, this means different instances of your queue processor will likely end up processing the same message.</p>
<p>When considering parallelism, this is mainly down to the API you're ingesting from. Depending on your deployment architecture (discussed below) the overall cost for the process may be similar regardless of whether you run 10, 20 or 50 processors in parallel. However, the API you're ingesting from might not be able to cope with this - at worst, you could end up crashing the API, or making it unresponsive for other users. In our scenario, the API we retrieve the data from is the same as that used by the front end application, so we needed to avoid this.</p>
<p>Clearly the two factors are linked. For example when evaluating the API we are ingesting from we discovered that:</p>
<ul>
<li>larger batch sizes are more efficient because the system behind the API caches the query, so retrieving subsequent pages for a batch is relatively fast compared to retrieving the first page.</li>
<li>smaller page sizes are better, as the API struggled to serialize large pages of data quickly.</li>
<li>the API could reliably cope with running 12 processors in parallel; more than that started to significantly impact production performance for other users. However, we also established that we got better results when running the ingestion process outside working hours.</li>
</ul>
<p>This experimentation process needs to be done carefully. If you are experimenting on a production API, it's likely you need to work with the owners to ensure you don't end up rendering their API unsable. If you're working in a sandbox environment, you need to bear in mind that it will likely have a different allocation of compute resources to the production environment, so you may need to include levers to allow you to tune the process once you reach production.</p>
<p>Underpinning this experimentation is ensuring you have baked in observability so that you can evaluate the resulting telemetry to inform your decision - more on this later.</p>
<h2 id="deployment-architecture">Deployment Architecture</h2>
<p>In production, this pattern runs on Azure Container Apps, which provides the perfect hosting model for this workload.</p>
<h3 id="container-jobs">Container Jobs</h3>
<p>Azure Container Apps Jobs are designed for workloads that run to completion then exit - exactly what we need.</p>
<p>We have a single container job which is called with arguments to either run the enqueuing or processing. The job can be started multiple times to support parallelism.</p>
<h3 id="container-size">Container size</h3>
<p>ACA offers a variety of combinations of CPU and memory, which allows you to select an appropriate. The exact options depend on whether you're using a Dedicated plan or a Consumption one. We used a Consumption plan, which allows you to size your container from as small as 0.25 cores and 0.5 GB RAM up to 4 cores and 8 GB RAM (as of March 2026).</p>
<p>Since we're not doing a lot of processing, but we are processing reasonably large chunks of data, we chose the 0.5 core and 1 GB RAM option, and it's serving us well. As with most other things, choosing the right container size is a matter of doing some initial calculations and then experimenting until you achieve consistent performance and reliability.</p>
<h2 id="orchestration">Orchestration</h2>
<p>Although we're running the enqueuing and processing logic in Azure Container apps, we're still orchestrating things via a Synapse pipeline. We chose this route for consistency; we're orchestrating all of the other processes in our data platform via Synapse pipelines and we didn't want to introduce other approaches.</p>
<p>We've created a pipeline that can trigger an Azure Container App Job, passing in the necessary parameters. This can run either as a fire-and-forget process, or it can poll for job completion. We use the former for the full ingestion, and the latter for the incremental ingestion as this allows us to immediately trigger data processing once ingestion is complete.</p>
<h3 id="cost-optimization">Cost Optimization</h3>
<p>Container Apps Jobs only consume resources while running:</p>
<ul>
<li>Enqueue job: Runs for ~1 minute, costs negligible</li>
<li>Worker jobs: Run for ~2 hours during full ingestion</li>
<li>Zero cost when idle - no minimum running instances required</li>
</ul>
<p>This is significantly more cost-effective than keeping functions warm or maintaining always-on compute resources.</p>
<h3 id="scaling-strategy">Scaling Strategy</h3>
<p>While Azure Container Apps supports automatic queue-based scaling using KEDA, we've found that starting with a fixed number of workers (12) works well for predictable workloads. For unpredictable workloads, especially when the target API can support a higher rate of requests than ours, you could configure scaling rules based on queue depth:</p>
<pre><code class="language-text">Queue depth &gt; 1000 messages → Scale to 50 workers
Queue depth &lt; 100 messages → Scale to 10 workers
Queue empty → Scale to 0
</code></pre>
<h3 id="observability">Observability</h3>
<p>OpenTelemetry tracing provides end-to-end visibility. Each work item creates its own trace span, making it easy to:</p>
<ul>
<li>Identify slow API endpoints</li>
<li>Track which work items failed and why</li>
<li>Identify failure patterns</li>
<li>Measure end-to-end ingestion duration</li>
<li>Correlate all work items for a specific ingestion job using the correlation_id</li>
</ul>
<h2 id="benefits-beyond-the-original-requirements">Benefits Beyond the Original Requirements</h2>
<p>While we initially focused on solving the core challenges of scale, fault tolerance, and speed, the pattern has delivered several unexpected benefits:</p>
<p><strong>Incremental ingestion came for free</strong>: Once the framework was in place, adding incremental ingestion was trivial. We created a new work item type (<code>IngestItemsModifiedSinceWorkItem</code>) that queries for recently modified assets instead of ID ranges. The dispatcher, queue processing, and retry logic all work identically.</p>
<p><strong>Debugging became significantly easier</strong>: When something goes wrong, the poison queue contains the exact work item that failed, complete with all parameters. We can inspect it, fix the underlying issue (often a data quality problem in the source system), then manually re-queue it without reprocessing thousands of successful items. This has saved hours of debugging time.</p>
<p><strong>Testing improved dramatically</strong>: Previously, testing the full ingestion pipeline required running against the actual API or building complex mocks. Now we can test individual processors in isolation by constructing work items with test data. Integration tests can enqueue a few work items and verify the results without processing the entire dataset.</p>
<p><strong>Cost visibility is excellent</strong>: Azure Storage Queue operations cost approximately £0.28 per million operations. For our 20,000-message ingestion, that's less than £0.01 in queue costs. The predictable, pay-per-use model makes capacity planning straightforward.</p>
<p><strong>Ability to modify the structure of the written data</strong>: Data is received from our source system arrives as partially formatted JSON. This can cause issues with some processing libraries, such as Polars. We ended up rewriting the retrieved data using the ndjson format, which Polars can process with no issues.</p>
<h2 id="when-to-use-this-pattern">When to Use This Pattern</h2>
<p>The queue-of-work pattern is ideal when:</p>
<ul>
<li>You need to make thousands or millions of API calls</li>
<li>Work can be divided into independent, idempotent units</li>
<li>Fault tolerance is critical (some items may fail, but others should continue)</li>
<li>You want to scale horizontally by adding more workers</li>
<li>Processing time per item varies significantly</li>
<li>You need to prioritize certain types of work</li>
</ul>
<p>It may be overkill for:</p>
<ul>
<li>Small-scale operations (&lt; 100 items)</li>
<li>Tightly coupled sequential processing</li>
<li>Real-time streaming (consider event-driven architectures instead)</li>
<li>Simple ETL where Synapse pipelines or Data Factory are sufficient</li>
</ul>
<h2 id="expansion-to-a-second-data-source">Expansion to a second data source</h2>
<p>We've since extended the pattern to a second data source in the same solution, Amazon Elasticsearch. This required us to go through the same process of evaluation and experimentation again, and brought with it a new consideration.</p>
<p>This time, the main benefit of the pattern is not in error handling, as the Elasticsearch service is highly resiliant and less susceptible to transient errors. However, it is highly scalable so means we can use a much higher degree of parallelism than with our original service.</p>
<p>Finally, size of individual data items is relatively small, but the volume of items we are retrieving is high, as each item represents a user event in the system. Regardless, we found that retrieving a day's worth of data at a time worked well.</p>
<p>Because of the high degree of parallelism permitted, a full ingestion of several year's worth of data takes seconds rather than the hours it would likely take if using a Synapse pipeline with an HTTP connector.</p>
<p>However, we had an additional consideration for this source; an IP allow-list on the service. In Synapse pipelines, this is dealt with using a Self Hosted Integration Runtime (SHIR) to allow you to ingest data from a well-known IP address. Fortunately, we already had the necessary infrastructure in place in our Azure Container App Environment, but this is something to be wary of when introducing additional moving parts into a solution.</p>
<h2 id="conclusion">Conclusion</h2>
<p>The queue-of-work pattern transformed what initially seemed like an intractable data ingestion problem into a manageable, scalable solution. By decoupling work distribution from execution through Azure Storage Queues, we achieved the performance and reliability goals that seemed out of reach with traditional orchestration approaches.</p>
<p>The pattern has run in production for since mid 2024, ingesting large volumes of records weekly from multiple source systems. It's proven robust enough that ingestion failures are now rare, and when they do occur, the poison queue pattern makes debugging straightforward.</p>
<p><strong>Key takeaways from this implementation:</strong></p>
<ol>
<li><strong>Plan for the worst case</strong>: In a perfect world, we could potentially have lived with the sequential ingestion, as we'd only need to do the full ingestion once and then keep everything in sync. However, there are plenty of error scenarios, and these are what you need to make sure your process can handle.</li>
<li><strong>Simple is fast</strong>: The basic queue-and-worker model introduces minimal overhead (&lt;50ms per operation) compared to complex orchestration systems</li>
<li><strong>Built-in resilience is valuable</strong>: Azure Storage Queues' native retry semantics and dequeue counting eliminated the need for custom retry logic</li>
<li><strong>Horizontal scaling works</strong>: Adding workers linearly improves throughput without coordination overhead</li>
<li><strong>Progressive completion aids debugging</strong>: Seeing results immediately rather than waiting hours made development and testing significantly faster</li>
<li><strong>Determining batch sizes and concurrency limits is an experimental process</strong>: It will likely take time to determine the limits of the API you are using and there will be a number of factors to take into account. If you have a sandbox environment for the API, it will likely have different characteristics to production. And even if you find the limits of concurrency that an API can support, the owners of that API will likely not want you to push it to the limit as this could negatively impact other users.</li>
</ol>
<p><strong>When this pattern works well:</strong></p>
<p>This approach excels when you can break work into independent units that don't depend on each other's results. The API ingestion scenario is ideal - fetching assets with IDs 1000-5999 has no dependency on fetching assets 6000-11999. If your workload requires sequential processing or complex dependencies between tasks, this pattern may not be the best fit.</p>
<p>If you've made it this far, thanks for reading! If you've got questions about implementing this pattern or would like to discuss your specific use case, feel free to leave a comment below. And as a final reminder, sample code to go with this blog post <a href="https://github.com/endjin/python-queue-of-work-pattern-demo">can be found in GitHub</a>.</p>]]></content:encoded>
    </item>
    <item>
      <title>Rx.NET v7 and Futures On .NET Live talk and demos</title>
      <description>In a recent On .NET Live stream, Ian Griffiths talked about recent developments in Rx.NET and plans for v7 and future versions. This post explains where to find the demo code for that session.</description>
      <link>https://endjin.com/blog/rx7-ondotnet-live-demos</link>
      <guid isPermaLink="true">https://endjin.com/blog/rx7-ondotnet-live-demos</guid>
      <pubDate>Fri, 27 Feb 2026 06:30:00 GMT</pubDate>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>Rx</category>
      <category>Rx.NET</category>
      <category>Rx7</category>
      <category>Reactive</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/02/rx-dotnet-v7-ondotnet-live-demos.png" />
      <dc:creator>Ian Griffiths</dc:creator>
      <content:encoded><![CDATA[<p>The <a href="https://dot.net/live">On .NET Live</a> team recently had me on as a guest to talk about Rx.NET v7 and future plans for Rx. You can see the <a href="https://endjin.com/what-we-think/talks/reactive-extensions-for-dotnet-rxdotnet-v7-and-futures">talk</a> here:</p>
<p></p><div class="responsive-video pull-wide"><iframe src="https://www.youtube.com/embed/8OAvFiczZ2k" frameborder="0" allowfullscreen=""></iframe></div><p></p>
<p>I showed some demos during the talk, and there was a request in the chat to make the source available. You can find the demos here:</p>
<p><a href="https://github.com/endjin/rx-ondotnetlive-demos-2026">https://github.com/endjin/rx-ondotnetlive-demos-2026</a></p>
<h2 id="ais.net-rx.net-and-wpf">AIS.NET, Rx.NET and WPF</h2>
<p>The <a href="https://www.youtube.com/watch?v=8OAvFiczZ2k&amp;t=296s">first demo</a> (source code at <a href="https://github.com/endjin/rx-ondotnetlive-demos-2026/blob/main/src/WpfAis"><code>src/WpfAis</code></a>) presents a map showing the current position of ships around the Norwegian coast:</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/02/aisrxwpf.png" alt="Windows application showing a map of Norway with various coloured arrows on it indicating the location of ships, each labelled with the ship's name" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/02/aisrxwpf.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/02/aisrxwpf.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/02/aisrxwpf.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/02/aisrxwpf.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>This uses a data source provided by the Norwegian government. They monitor <a href="https://en.wikipedia.org/wiki/Automatic_identification_system">AIS</a> data broadcast by ships around their coast, and make these messages available on a public endpoint. Endjin provides an open source suite of libraries collectively called <a href="https://github.com/ais-dotnet">Ais.Net</a> that can be used to process data of this kind. This example uses the <a href="https://www.nuget.org/packages/Ais.Net/">Ais.Net NuGet package</a> to process the raw messages. It also uses the <a href="https://www.nuget.org/packages/Ais.Net.Receiver/">Ais.Net.Receiver package</a> to retrieve messages from the Norwegian government's service, and to present those through Rx.NET.</p>
<div class="aside"><p>Endjin maintains AIS.NET. I last wrote about it in a recent blog post about the <a href="https://endjin.com/blog/how-dotnet-10-boosted-ais-dotnet-performance-by-7-percent-for-free">performance improvements .NET 10 has brought to this library</a>.)</p>
</div>
<p>This example shows how Rx.NET can be used to process live data streams declaratively. In particular, this effectively performs a 'join' over two kinds of message. Ships broadcast their names and types in different messages from the ones they use to report their locations (because their name and type tends to change a lot less often than their location). But the UI wants to combine this information so that it can display the ship's name and type over its location icon in the map.</p>
<p>The demo's viewmodel <a href="https://github.com/endjin/rx-ondotnetlive-demos-2026/blob/4707e72bab9ed6380499b4007895bb66b09a19d2/src/WpfAis/WpfAis/MapViewModel.cs#L30-L40">expresses this declaratively</a> in Rx.NET:</p>
<pre><code class="language-cs">IObservable&lt;IGroupedObservable&lt;uint, IAisMessage&gt;&gt; byVessel =
    receiverHost.Messages.GroupBy(m =&gt; m.Mmsi);
var vesselNavigationWithNameStream =
    from perVesselMessage in byVessel
    let vesselNavigationUpdates = perVesselMessage.OfType&lt;IVesselNavigation&gt;()
    let vesselNames = perVesselMessage.OfType&lt;IVesselName&gt;()
    let shipTypes = perVesselMessage.OfType&lt;IShipType&gt;()
    let vesselLocationsWithNames = Observable.CombineLatest(vesselNavigationUpdates, vesselNames, shipTypes,
        (navigation, name, type) =&gt; (navigation, name, type))
    from vesselLocationAndName in vesselLocationsWithNames
    select (mmsi: perVesselMessage.Key, vesselLocationAndName.name, vesselLocationAndName.navigation, vesselLocationAndName.type);
</code></pre>
<p>This uses the C# <a href="https://learn.microsoft.com/en-us/dotnet/csharp/linq/get-started/query-expression-basics">query expression syntax</a> to describe the processing we require. The Rx.NET library does all the actual work for us. (If you'd like more information about how this works, I've shown a version of this query before at <a href="https://youtu.be/K5A3uP75XNQ?t=1154">this talk</a>.)</p>
<h2 id="system.linq.async.net-10-and-system.linq.asyncenumerable">System.Linq.Async, .NET 10, and System.Linq.AsyncEnumerable</h2>
<p>During the show, I <a href="https://youtu.be/8OAvFiczZ2k?list=PLdo4fOcmZ0oV2fcY7wsQHx4RNWXEDKgm4&amp;t=1467">talked about</a> how for many years, the de facto implementation of LINQ for <code>IAsyncEnumerable&lt;T&gt;</code> <a href="https://www.nuget.org/packages/System.Linq.Async"><code>System.Linq.Async</code></a> was not, despite how the name makes it look, an officially supported library. It has always lived in the Rx.NET repo, and when we at endjin took over maintenance of Rx.NET, that meant we also became responsible for LINQ to <code>IAsyncEnumerable&lt;T&gt;</code>! (See the <a href="https://www.youtube.com/watch?v=Ktl8K2b1-WU">announcement video for the old <code>System.Linq.Async</code> package</a> for more information on the history behind this.)</p>
<p>With .NET 10, this is now finally built into the .NET runtime libraries. The <a href="https://www.nuget.org/packages/System.Linq.AsyncEnumerable"><code>System.Linq.AsyncEnumerable</code></a> package (which <em>is</em> officially supported by Microsoft) is included as part of .NET 10.0 but is also available for use on older runtimes.</p>
<p>In the demo I showed how this creates a potential problem for projects already using <a href="https://www.nuget.org/packages/System.Linq.Async"><code>System.Linq.Async</code></a>. The project at <a href="https://github.com/endjin/rx-ondotnetlive-demos-2026/blob/main/src/SysLinqDemo"><code>src/SysLinqDemo</code></a> targets .NET 8.0 and uses <a href="https://www.nuget.org/packages/System.Linq.Async"><code>System.Linq.Async</code></a> v6. If you upgrade the project to use .NET 10.0, you'll get errors about ambiguous definitions of the LINQ operators:</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/02/whereambiguous.png" alt="Visual Studio showing part of an error message popup indicating that the Where method call is ambiguous in this example" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/02/whereambiguous.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/02/whereambiguous.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/02/whereambiguous.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/02/whereambiguous.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>I showed that simply removing the reference to <code>System.Linq.Async</code> is the simplest way to resolve this. But I also discussed how you might not be able to do that because you might be using some other library that has a dependency on it. So I also showed how upgrading to the latest version of <code>System.Linq.Async</code> (v7) also fixes this problem.</p>
<h2 id="rx-7-and-ui-framework-support">Rx 7 and UI framework support</h2>
<p><a href="https://youtu.be/8OAvFiczZ2k?list=PLdo4fOcmZ0oV2fcY7wsQHx4RNWXEDKgm4&amp;t=2265">I discussed how our main goal with Rx 7</a> is to fix the bloat problems that have for many years afflicted applications that use Rx.NET in conjunction with self-contained deployment when targetting Windows. This table shows the impact this problem can have on a simple 'hello world' console app:</p>
<table>
<thead>
<tr>
<th>Deployment type</th>
<th>Size without Rx</th>
<th>Size with Rx</th>
</tr>
</thead>
<tbody>
<tr>
<td>Framework-dependent</td>
<td>20.8MB</td>
<td>22.5MB</td>
</tr>
<tr>
<td>Self-contained</td>
<td>90.8MB</td>
<td><strong>182MB</strong></td>
</tr>
<tr>
<td>Self-contained trimmed</td>
<td>18.3MB</td>
<td>65.7MB</td>
</tr>
<tr>
<td>Native AOT</td>
<td>5.9MB</td>
<td>17.4MB</td>
</tr>
</tbody>
</table>
<p>For framework-dependent deployment, in which the .NET runtime is presumed already to be available, adding Rx.NET has a relatively small impact. But for any of the other options, a reference to <code>System.Reactive</code> can double or even triple the size of the deployment! This happens when you target a Windows-specific TFM because <code>System.Reactive</code> ends up forcing your project to depend on both WPF and Windows Forms. You end up deploying a copy of both of those frameworks, which is what's taking up all the space here. The only way we can fix this is to split out UI framework support from the main <code>System.Reactive</code> package, putting these features into more specialized packages.</p>
<p>The effect of this change is that for the four deployment models shown, the impact of adding Rx becomes roughly 1.6MB, 1.6MB, unmeasureably small, and 300KB respectively.</p>
<p>To demonstrate what this will look like for developers, I showed a simple WPF application (in <a href="https://github.com/endjin/rx-ondotnetlive-demos-2026/blob/main/src/WpfWithRx"><code>src/WpfWithRx</code></a>) that uses Rx.NET v6, and which relies on its <code>ObserveOnDispatcher</code> method to ensure that Rx notifications are handled on a suitable thread for performing UI updates. I then upgraded the project to the preview of Rx 7 to show what developers will see when they do this:</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/02/rx7wpfdiagnostic.png" alt="Source code with ObserveOnDispatcher showing a squiggly line and a tooltip showing two messages. One, a CS1061 error, indicates that this method is not available. The second, an RXNET0002 diagnostic, explains that this methods has moved, and that a reference to the System.Reactive.Wpf NuGet package is now required" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/02/rx7wpfdiagnostic.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/02/rx7wpfdiagnostic.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/02/rx7wpfdiagnostic.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/02/rx7wpfdiagnostic.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>This illustrates that they will now get a build failure on code that expects UI framework support to be in the main Rx.NET package. But it also shows that Rx.NET v7 has an analyzer that detects this, and explains exactly how to resolve the problem. We hope this will make the transition relatively smooth for projects affected by this breaking change. (We also maintain binary compatibility—although the WPF and Windows Forms features have been removed from the public-facing API, they are actually still present in the runtime binaries.)</p>
<p>We've done this by writing a <a href="https://learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/tutorials/how-to-write-csharp-analyzer-code-fix">custom analyzer</a>. (This is in the <a href="https://github.com/dotnet/reactive/tree/687a7e865bad90991859e876ba1bc67b0cd3923b/Rx.NET/Source/src/System.Reactive.Analyzers"><code>Rx.NET/Source/src/System.Reactive.Analyzers</code> folder</a>.) This is an extra DLL in the <code>System.Reactive</code> NuGet package that gets loaded by the IDE (Visual Studio, VS Code, or Rider will all find it). Analyzers inspect source code to find problems, and make suggestions for how to fix them.</p>
<p>(Ideally we would have supplied a Code Fix as well as an analyzer. The .NET SDK's analyzer mechanisms allow analyzers to propose code changes, which show up as 'fixes' suggested by the IDE. However, there isn't currently a good way for a fix to suggest changes to the <code>.csproj</code> file, which is what's required in this case. It seems that the only supported API for making the changes the fix would need to make is the old <code>EnvDTE</code> automation APIs offered by Visual Studio. However, that isn't available on other IDEs, so it wouldn't work on VS Code or Rider. And even in Visual Studio, there isn't a supported way for a code fix to get hold of that API. This surprised us a little, because these IDEs do actually make suggestions for adding NuGet package references in some other situations, but as far as we can see, there's no way for our analyzer/code fix to trigger that.)</p>
<p>Now that we've got an analyzer DLL built into <code>System.Reactive</code>, it would also be possible to start adding other analyzer rules: perhaps we could spot problematic coding patterns. If you have any ideas for common Rx issues that you think an analyzer could detect, please suggest them in <a href="https://github.com/dotnet/reactive/issues">https://github.com/dotnet/reactive/issues</a></p>]]></content:encoded>
    </item>
    <item>
      <title>Reactive Extensions for .NET - Rx.NET v7 and Futures</title>
      <description>&lt;p&gt;Ian Griffiths, Technical Fellow at endjin, .NET MVP, and author of Programming C# (O'Reilly), returns to On .NET Live to demo Rx.NET with live ship-tracking data from Norway's AIS network and walk through the major changes coming to the Reactive Extensions ecosystem in v7.&lt;/p&gt;
&lt;p&gt;In this episode:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;🚢 Live demo — streaming real-time vessel data with Rx.NET and AIS.NET, using LINQ queries over observable sequences to join, group, and display ship positions on a WPF map&lt;/li&gt;
&lt;li&gt;📦 System.Linq.Async → System.Linq.AsyncEnumerable in .NET 10 — how LINQ for IAsyncEnumerable moved from the Rx repo into the .NET runtime, and what that means for your projects&lt;/li&gt;
&lt;li&gt;⚠️ Rx 7 Preview — unbundling WPF and Windows Forms support from System.Reactive to eliminate the 90MB binary bloat in self-contained deployments&lt;/li&gt;
&lt;li&gt;🔮 Rx 8 and beyond — plans for browser WASM and Unity support, improved trimability, and the path to production-ready Async Rx&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="links-resources"&gt;Links &amp;amp; resources:&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/dotnet/reactive"&gt;Rx .NET repo&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://introtorx.com"&gt;FREE Ebook - Introduction to Rx .NET&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/dotnet/reactive/blob/main/Rx.NET/Documentation/adr/0005-package-split.md"&gt;Rx .NET 7 Package Split ADR&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/dotnet/core/compatibility/core-libraries/10.0/asyncenumerable"&gt;System.Linq.AsyncEnumerable in .NET 10&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ais-dotnet/"&gt;AIS.NET&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://endjin.com/blog/how-dotnet-10-boosted-ais-dotnet-performance-by-7-percent-for-free"&gt;How .NET 10.0 boosted AIS.NET performance by 7%&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://endjin.com/blog/"&gt;endjin blog&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.reactiveui.net/"&gt;ReactiveUI&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <link>https://endjin.com/what-we-think/talks/reactive-extensions-for-dotnet-rxdotnet-v7-and-futures</link>
      <guid isPermaLink="true">https://endjin.com/what-we-think/talks/reactive-extensions-for-dotnet-rxdotnet-v7-and-futures</guid>
      <pubDate>Wed, 25 Feb 2026 06:30:00 GMT</pubDate>
      <category>Reactive Extensions</category>
      <category>dotnet</category>
      <category>Rx.NET</category>
      <category>Open Source</category>
      <category>.NET Foundation</category>
      <category>Talk</category>
      <enclosure length="0" type="image/jpeg" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/rx-dotnet-v7-and-futures.jpg" />
      <dc:creator>Ian Griffiths</dc:creator>
      <content:encoded><![CDATA[<p>Ian Griffiths, Technical Fellow at endjin, .NET MVP, and author of Programming C# (O'Reilly), returns to On .NET Live to demo Rx.NET with live ship-tracking data from Norway's AIS network and walk through the major changes coming to the Reactive Extensions ecosystem in v7.</p>
<p>In this episode:</p>
<ul>
<li>🚢 Live demo — streaming real-time vessel data with Rx.NET and AIS.NET, using LINQ queries over observable sequences to join, group, and display ship positions on a WPF map</li>
<li>📦 System.Linq.Async → System.Linq.AsyncEnumerable in .NET 10 — how LINQ for IAsyncEnumerable moved from the Rx repo into the .NET runtime, and what that means for your projects</li>
<li>⚠️ Rx 7 Preview — unbundling WPF and Windows Forms support from System.Reactive to eliminate the 90MB binary bloat in self-contained deployments</li>
<li>🔮 Rx 8 and beyond — plans for browser WASM and Unity support, improved trimability, and the path to production-ready Async Rx</li>
</ul>
<h2 id="links-resources">Links &amp; resources:</h2>
<ul>
<li><a href="https://github.com/dotnet/reactive">Rx .NET repo</a></li>
<li><a href="https://introtorx.com/">FREE Ebook - Introduction to Rx .NET</a></li>
<li><a href="https://github.com/dotnet/reactive/blob/main/Rx.NET/Documentation/adr/0005-package-split.md">Rx .NET 7 Package Split ADR</a></li>
<li><a href="https://learn.microsoft.com/en-us/dotnet/core/compatibility/core-libraries/10.0/asyncenumerable">System.Linq.AsyncEnumerable in .NET 10</a></li>
<li><a href="https://github.com/ais-dotnet/">AIS.NET</a></li>
<li><a href="https://endjin.com/blog/how-dotnet-10-boosted-ais-dotnet-performance-by-7-percent-for-free">How .NET 10.0 boosted AIS.NET performance by 7%</a></li>
<li><a href="https://endjin.com/blog/">endjin blog</a></li>
<li><a href="https://www.reactiveui.net/">ReactiveUI</a></li>
</ul>
<p><a href="https://www.youtube.com/watch?v=8OAvFiczZ2k?t=187"><img src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/rx-dotnet-v7-and-futures.jpg"></a></p><p><strong>Katie Savage:</strong> Hello everybody and welcome back to On .NET Live, where it's our mission to teach the .NET community to achieve more. This morning, or afternoon, or evening depending on where you are, we have an awesome show prepared for you. I'm one of your hosts — this is Katie Savage, here with Cam Soper and Frank Boucher. And I'm sorry, Frank, for my horrendous French accent. I don't have one. It's nonexistent. But I'm super excited to introduce our guest today, Ian Griffiths, who is actually a returning guest. Ian, could you tell us a little bit about yourself?</p>
<p><strong>Ian Griffiths:</strong> So hi, I'm Ian Griffiths. I am an author with O'Reilly — I've written the last four or five editions of <em>Programming C#</em>. I've also long been a Pluralsight instructor. Going way back, I got started in computing doing kernel mode device drivers and embedded systems, and then I've gradually been working my way up the stack since then — through medical imaging, broadcast video systems, and then into UI stuff. And then more recently into data analytics and of course applications of that to AI these days.</p>
<p>I currently work as a Technical Fellow for endjin, who are the sponsors of the Reactive Extensions for .NET these days. So the reason Rx.NET, which we're going to talk about today, is still alive and ticking is the generosity of my employers. So thank you very much to them. And that's me.</p>
<p><strong>Katie:</strong> Awesome, thank you so much for taking us through that. And you gave us a bit of a spoiler, but I'd love to hear more about what you're talking about today. And I believe last time you talked a little bit about this topic as well.</p>
<p><strong>Ian:</strong> I was last on I think two and a half years ago, and at that point endjin had just taken over the maintenance of the Reactive Extensions for .NET. So Reactive Extensions, or Rx.NET for short — also known by the NuGet package name System.Reactive — these are one of the oldest open source projects actually in the Microsoft world. It originally came out of Microsoft.</p>
<p>The Reactive Framework was originally created by the Cloud Programmability Group inside of Microsoft back in around 2008. And it's the same people behind it as were behind LINQ — Language Integrated Query. It kind of came out of that. So it was Erik Meijer's team, essentially, that came up with this back in the day. Same people invented LINQ, same people — some of the same people who are behind the async language features for C# as well. So a very interesting team, and they created this thing called Rx.NET.</p>
<p>And the way I like to describe it is: Rx is useful in any program where things happen. So that's quite a broad category, although not everything — there are exceptions to this. Programs that basically reach into a database where the data's just sat there and do some processing and then write some results out — nothing really happens there. There's sort of data in and data out, and it's all like a batch process. Whereas applications where things are happening live tend to require a slightly different approach.</p>
<p>So Rx has been most well known, I guess, in the user interface world, because there things happen — the user interacts with the application and you need things to happen in response to that, and Rx is really good for that.</p>
<p>But it can also be used in things like monitoring applications. So if you imagine, for example, utility companies — one of the projects that endjin has done with this is we worked with a utility company that provided broadband services, and we were modelling all of the diagnostic data from their multiple millions of routers in people's homes that were reporting information about the state of the network, to provide analytics that were live so that they could, for example, see problems that were unfolding in their network before the customers were troubled by it.</p>
<p>And so you needed to be able to monitor literally millions of devices and to get analytics — some of which were kind of specific to the devices, some of which were at a more aggregate level. So you wanted to know: has this connection gone down because someone's just accidentally watering their plants and has drowned their router, or is it because the exchange is on fire? Those are two quite different conditions and the appropriate response for those two things is different.</p>
<p>And so the sort of analysis you want to do in real time in response to this changing data — it's very useful to be able to do stream analytics at various different levels of detail. And that's the kind of thing that Rx is also very good at. It's less obvious than perhaps the user interface type approaches, but it's equally valuable in that kind of live data analytics world.</p>
<p><strong>Katie:</strong> I can imagine, and I'm super intrigued already by the scenarios you've brought up. And honestly that tagline is perfect — I would love to put that on my LinkedIn bio: "useful on any project where something happens." That's amazing. But I'd love to learn more about Rx and I'd love to have you get into it.</p>
<p><strong>Ian:</strong> Okay, well I think possibly the best thing to start with would be a demo. So I've got a little WPF application here. Now those of you who did watch the same talk two and a half years ago would've seen an earlier iteration of this — let me get that on the right screen.</p>
<p>So just to show you what the thing does before I get into the code: this is obviously a map control, and as this runs for a while you'll see gradually appearing on the map these little markers. And what we're seeing here is actually live information, generously provided by the Norwegian government, basically for free. They provide this online service that reports the location and movement of all — basically all — ships anywhere near Norwegian waters.</p>
<p>So there's this standard called AIS, the Automatic Identification System, which basically any large or moderately sized vessel that operates in international waters is legally obliged to have. It's basically GPS plus a radio transmitter, so the ships can report where they are. So if you have a marine GPS system, you can see where the other vessels are, and that's because they're all broadcasting their information.</p>
<p>Now this is all the ships in Norway, so this gets quite busy quite quickly. But you can see if I zoom in, you can see it showing where the things are, the direction they're heading in. The API also offers things like speed. It tells you what kind of vessel they are. It will tell you whether they're moored or whether they're anchored or whether they're moving or whether they're doing diving operations. There's quite a lot of information there. This is obviously live, so these things will gradually move around. You saw it populate as I started to go, so this is kind of a good example of the sort of data that we might actually want to deal with in Rx.</p>
<p>So just to give you a flavour of how this looks, I'm going to kill it off and we can start to look at the code. And actually what I'll start with — I've got a couple of projects here — I've got a simpler console app that lets us just see how the API works at its most basic level.</p>
<p>So if I go and open up the Program.cs here: we have this API that lets us connect to servers that can provide AIS data. So that IP address happens to be the IP address of the service provided by the Norwegian government. If you connect to them on this port, they will give you AIS data.</p>
<p>And this library we're using here is a library called Ais.NET, also written and maintained by my employer, endjin — thanks again. So this is a free library that lets you process AIS messages and it exposes them through this property here, which is of type <code>IObservable&lt;AisMessage&gt;</code>.</p>
<p>So here, this is where Rx comes into the picture. This interface, <code>IObservable&lt;T&gt;</code> — it's actually built into the .NET runtime class library. So you don't need any extra libraries to have this. This has been built in since .NET Framework version 4.0. This was actually baked right into the framework.</p>
<p>All of the other support around it isn't, but that interface is baked in. This is the core of Rx. It represents a sequence of things, and in this case the things are AIS messages. So this basically says: if you've got a receiver host plugged into an AIS data source, it will give you a sequence of messages.</p>
<p>And right now this program is incredibly simple. It says: I would like to subscribe to that source of messages, and each time a message arrives, I want it to invoke this function, which just says "okay, what kind of message was it? Was it one that tells us the vessel's name? Does it tell us where the vessel is?" and prints out the details.</p>
<p>So if I run this one — and get that onto the right screen, let's make this a little bit bigger — we can see what the raw messages look like. I'm going to stop that and take a look at it, because this is actually going to show you a challenge with this data source that we're going to use Rx to address.</p>
<p>So Rx isn't just about receiving sequences of messages. If that was all it did, it wouldn't be very interesting. But it allows us to perform processing on those messages in a declarative way. So let's look at these messages.</p>
<p>So this is basically the raw data — or some of the raw data — being received from the ships, and they send different types of messages. So sometimes they send out a message saying "I am at this location and I am facing in" — that's not really a compass bearing, because compasses don't go up to 511. That's a magic value saying "I don't know which way I'm facing right now, so please ignore this," and "I'm not moving." Some of them will have more interesting information.</p>
<p>So there we go — that looks like a genuine compass bearing. So this one says: "I am currently engaged in fishing. I'm currently at these GPS coordinate locations, and I'm facing in this direction." And since I'm fishing, I'm not currently moving. So you can get this information about what vessels are doing, and they're all tagged with a unique vessel identifier.</p>
<p>So when you install one of these GPS systems, they have a unique vessel ID. Now some of these things say what the name of the boat is, so you can see this one here is saying "my name is Cans 21." And a lot of spaces — sometimes they seem to pad the space out with at symbols for some reason. Don't know why, but they do.</p>
<p>Now on the map, I wanted to label each of the nodes to say "the vessel here is called this, the vessel there is called that." But there's a slight problem, because the messages that tell us where they are don't include the name, and the messages that include the name don't say where they are.</p>
<p>And the reason for this is that boats tend to move more often than they change their names. And so they do not broadcast their names as often as they broadcast their locations. And because this whole radio standard is actually using relatively low frequency radio, the bandwidth is minuscule — there's maybe hundreds of bits per second. It's incredibly low bandwidth, and so they don't have a lot of space in these messages. And so they try and maximise efficiency.</p>
<p>But then, okay — if I'm going to draw these things on a map, when I receive a message that says "we're at this location doing this thing in this direction," how am I going to reconcile that with the name that I'd like to stick on the label?</p>
<p>Essentially what I want to do conceptually is a join. It's like if this information was in a couple of database tables — I had a table of "here are all the names" with the primary key of the vessel ID, and another table saying "here are the locations" also with the key of the vessel ID — I would just join across those and then I'd be able to get the answer.</p>
<p>That's fine if the data's already there, if nothing's happening, if it's not live, if it's just data that's sat there that I can query. But how am I going to do this when it's live data?</p>
<p>Well, this is where Rx comes in. So if I go back to the WPF version, which is actually labelling these things, I've got the same basic code. I've got a view model here that's powering the display — this is basically sitting behind the UI that you see. And as with the console app, again I just create myself a receiver and a host for that receiver. This is the thing that gives me observable messages.</p>
<p>But now I'm doing some more interesting things. I am saying I want to not just process the messages — I want to start running processing operations on them. In this case, I would like to group the vessels by their unique ID. So what this says is: rather than having a single stream, I want to get an observable stream of observable streams. So each time this sees a vessel it hasn't seen before — each time it sees a message where the vessel identifier is not the same as one it saw last time — it's going to emit a new group as the item that comes out of this observable.</p>
<p>So this is an emitter of groups. It's a sequence of sequences. And then what I can do is say, okay, within each stream I'd like to pick out the different types of messages. I'd like to pick out the navigation messages and the messages that say what the name is, and also the ones that say what kind of ship this is — is it a fishing vessel? Is it a tug? Is it a tanker? Is it something else?</p>
<p>And then combine those together. So I'm sort of doing a join here. In essence, I'm not actually using join syntax, but logically it's doing that sort of thing. I'm telling the Rx library I would like to combine these streams together to find the latest location, name, and type information within this single vessel stream. So basically this is going to emit a series of: this vessel is called this name, it's at this location, and it's this type. And every time any one of those things changes — if it changes location or changes speed, or if less likely it changes its name or its type — then I'll get a new message comes out of here.</p>
<p>And then finally, I basically merge them all back together again. This actually, because it's got two <code>from</code> clauses in here, turns into a LINQ <code>SelectMany</code>, which is a flattening thing. It flattens it back down again. And the net result is the messages that actually come out basically tell you the combined vessel name, location, and type for each vessel.</p>
<p>So anytime you get new information about a vessel, it comes out of here, and this is what we can then use to update the UI. So now I'm just moving into the world of WPF — I'm picking a colour to paint the ship based on its type, I am setting its location on the map based on the reported location, and I'm labelling it with the name.</p>
<p>So we're now back in basically the world of WPF data binding at this point. The point is I can use C#'s built-in query syntax — or if you prefer you can just write LINQ queries as method invocations. Some people prefer one style, some people prefer the other. They are exactly equivalent. You can write it either way, and you can essentially execute queries over live data streams.</p>
<p>So when we run — hang on, I've restarted the console app. Let me go back to the WPF app, click the right button. So when this runs, it starts receiving messages. Initially it's going "okay, well I've got a location but I don't have the name yet," or "maybe I've got a name but I haven't got a location." But once it starts seeing messages of all types from a single vessel, it goes "oh, okay — right now I've seen a location and a name and a type for this particular one here. Now I can actually emit that as a single message with all three." Hand that over to the map, and off we go.</p>
<p>And if we sat here long enough, you would gradually see them moving across the screen. Although, being ships, they're going to move quite slowly at this scale, so I'm not going to sit and make you wait for that.</p>
<p>The basic idea then is we've got this abstraction — observable — which we can do all the same things with that we might do with an <code>IEnumerable</code>, because they are essentially the same fundamental idea. An <code>IEnumerable</code> is just one thing after another. An observable is just one thing after another. The difference is: with an <code>IEnumerable</code>, we as the programmer say "I'd like the next item please." You write a <code>foreach</code> loop over the thing — "give me the next item, you do some work, give me the next item, you do some work."</p>
<p>So we as the developer are pulling items out of the source, so to speak. Whereas with Rx, the source decides when it has something for you. I can't walk up to this API and say "make that ship over there emit a message for me." That's not going to happen. The ship's transmitter will emit messages when it wants to.</p>
<p>And so that happens on its own schedule. I as a developer am not in control of that. And so Rx gives me a way of expressing that by having these things be emitting sources. So we have what's called a push-like way of consuming them, where the source delivers messages into us. So that's the fundamental concept.</p>
<p>It's designed specifically to be very similar to <code>IEnumerable</code>. It's just that you receive messages when the source has them for you, rather than retrieving them when you are ready to process the next thing. That's basically the heart of Rx.</p>
<p>And then the same LINQ query language is basically available on both — pretty much anything you can do with LINQ to Objects like searching, filtering, joining, windowing, all these sorts of things are available. And actually, as it happens, Rx provides a bunch of extra operators that are specifically temporal in nature, that wouldn't really make sense for a database. So you can, for example, say "I would like a sliding window that is two seconds long and I'd like you to give me all the events that happen within a two-second window." So I can process those that way. And that obviously only makes sense in the presence of timing. And this being a push-oriented thing, timing is inherently there in a way that it's not with a raw database.</p>
<p>So that's the heart of Rx.</p>
<p><strong>Katie:</strong> It is super cool. When you started initially it was like "okay, we can buffer the messages and stuff" — no, just group them and it works. That's awesome.</p>
<p><strong>Ian:</strong> It's a declarative style. So rather than you having to think "what code am I going to write to process it, where am I going to put these things, how do I bunch them together?" — if you can express the semantics of what you are doing through the language of LINQ, through the standard query operators that LINQ provides, then you don't have to think about how you're doing it. You can just say what you would like done.</p>
<p><strong>Katie:</strong> That's pretty cool. Super cool. And Ian, one person here, John, is asking if there's a link for this code. They already want to start practising with this.</p>
<p><strong>Ian:</strong> Oh right. So I wrote this like a couple of hours ago, so no, not yet. There is a notebook you can get hold of — we used to do this as a Polyglot notebook. Unfortunately the Polyglot notebooks are kind of going away. I think that project is winding down now. And so I rewrote this as a plain WPF project this afternoon so I could be sure I could run it. I will endeavour to make this available. I will do a blog post about this just to follow up. So I will make this code available to people who want it. If you go to endjin.com, then you can find our blogs there. You can find me. I will make this available later this week.</p>
<p><strong>Katie:</strong> Perfect. Thank you. Good question, John.</p>
<p><strong>Ian:</strong> So what I was hoping to talk about today, if I may, is some of the stuff we've actually been doing in the Rx project. So this is kind of the intro, entry-level stuff. But we've actually been doing some things lately.</p>
<p>One of the things I talked about the last time I was on is documentation and kind of learning Rx, because one of the things with Rx is it's very powerful, but it's not the easiest thing in the world to learn. People often struggle to get their heads around it for a while and then eventually reach a kind of "aha" moment, where it's like "oh, I get it now," and suddenly you can't imagine programming without this whole mechanism. And getting people to that point has been challenging.</p>
<p>Now last time I was on, I was talking about — there was a site called IntroToRx.com, which was written actually like 14 years ago, believe it or not, by a guy called Lee Campbell. But he hadn't updated it since then, so it was very good but it was also kind of out of date. But he very generously allowed us to take that content and update it. And since the last time I was on, that is now done — up-to-date site, IntroToRx.com. So if you want to learn in detail about this stuff, that is absolutely the place to go.</p>
<p>IntroToRx.com — it's available. We also take contributions. The community is regularly submitting changes or fixes or enhancements to that, and so it's a live, up-to-date place to go to learn about this stuff. I would check that out if you are in any way interested in this.</p>
<p>So, any questions you want to raise as hosts before I — because I know I can fill the entire hour without noticing it's gone, so I don't want to completely take things over.</p>
<p><strong>Cam:</strong> Well there was — Cecil was asking — he thought that Rx and Ix operators were moving into the core framework.</p>
<p><strong>Ian:</strong> Ah yes. This is exactly one of the things I wish to talk about. So let me just — I'm going to open up a web browser. Probably didn't want to do it in the same window that I'm running the session in. Two seconds and I've lost my mouse pointer. Where's it gone? That's what happens when you have a lot of screens, people.</p>
<p><strong>Katie:</strong> I'm just jealous. I wish I had that problem.</p>
<p><strong>Ian:</strong> Okay. So this is actually the source code for the documentation, but this is up on the — if you go to the main .NET website you will — hang on, no, this is not the one I meant to do. I meant to look at the one that's on the main .NET website. Hang on a second. .NET 10. It's this one here.</p>
<p>So this was announced as a breaking change in .NET 10, which is that they have added support for LINQ to <code>IAsyncEnumerable</code>. So what does this have to do with Rx.NET? Because <code>IAsyncEnumerable</code> is this interface that was introduced to the .NET runtime roundabout .NET Core 3.1 time.</p>
<p>Let me just clear that out the way to make a bit more space. Right. So the basic idea is it's like <code>IEnumerable</code> — it's a sequence of things — but it's async. So you want to produce items in a way where you sometimes need to await in the implementation and block and have a task that might complete asynchronously. <code>IAsyncEnumerable</code> lets you model that.</p>
<p>In this example I've written this as a C# iterator method, and then they introduced in C# 8, I think it was, this <code>await</code> flavour of the <code>foreach</code> loop, which is designed to provide direct integration for this. So this is <code>IAsyncEnumerable</code>.</p>
<p>But when it was introduced back in .NET Core 3.1, the .NET team did not provide an implementation of LINQ for this. So you could not, for example, do <code>.Where(x =&gt; x is divisible by two)</code>. For example, if we try this, we get a squiggly saying "the type arguments for this method cannot be found."</p>
<p>However, the Rx team said "that's fine, we have an implementation of this that you can use." If you go to the NuGet package manager and if you search for System.Linq.Async — this says "provide language integrated query over <code>IAsyncEnumerable</code> sequences." And I'm going to add actually version six, and I'm going to just quickly zoom in on something if this will work. You may notice that the project URL is github.com/dotnet/reactive. That's the Rx repo. Mysteriously, this implementation lives in the Rx repository.</p>
<p>If we click — that squiggly goes away. So now if I run this, this basically reads input lines and emits them as numbers. So if I type 42, that will come through. If I type 43, it won't come through, because I filtered it to say I only want the even numbers. So those come through, those ones don't.</p>
<p>So this made LINQ available for <code>IAsyncEnumerable</code>, and this was released almost immediately after .NET Core 3.1 shipped.</p>
<p>Now, why, you might wonder, did the Rx team do this? What on earth does this have to do with Rx? Didn't I just say that Rx is all about push, whereas <code>foreach</code> is all about pull?</p>
<p>Well, here's the thing. The team, as I mentioned — the team that invented Rx, the people in it were also behind LINQ. And actually they invented <code>IAsyncEnumerable</code> first. <code>IAsyncEnumerable</code> had been around in .NET for about five years before it appeared in the .NET Core runtime itself, and it was written by the Rx team. They implemented this thing called <code>IAsyncEnumerable</code> themselves. And then later, several years later, the .NET team said "oh, that's useful, we should build that in." And they did.</p>
<p>And so the Rx team said "alright, we should probably stop trying to define it ourselves." They removed their own definition of <code>IAsyncEnumerable</code>, but it's like — hang on, we have a complete LINQ implementation for this. They'd already done it. They'd already done all the work to support LINQ for <code>IAsyncEnumerable</code> long before the .NET runtime and the C# compiler team built support for that interface in.</p>
<p>And so it was like "well, we could just make this available." And so they did. They made System.Linq.Async available as a library, because they just had it and people wanted it.</p>
<p>There was a slight problem with this, which is: by this time there wasn't really any full-time support inside of Microsoft for the Rx project. So it had started as a fully funded internal project and it turned into a fully community-supported project. And the result of this is that loads of people look at this and think "well, it's called System.Linq.Async — that must be part of the .NET runtime library. That must have full support. I must be able to demand feature enhancements and bug fixes for that, just like I could for any other bit of the .NET runtime." And those of us who are maintaining this in our own time for free were starting to get support requests from people who genuinely believed we were being paid to do this, when we weren't.</p>
<p>And when we decided to take over — to offer to take over maintenance of the Reactive Extensions for .NET — we didn't really sign up to also become the maintainers of LINQ for <code>IAsyncEnumerable</code>, but it sort of happened because it's the same repo. So we sort of became responsible for that as a direct result of taking over the repository.</p>
<p>So David Fowler actually said "this doesn't seem right, we should probably build this into the .NET runtime libraries." And he said that about two years ago, and that eventually came to fruition with .NET 10. So as of .NET 10, you don't need to do this. I can actually come in here and remove this library and this will go back to giving me an error. But if I now upgrade the project to .NET 10 and save that and build it — that squiggly should go away again. And now it's there, but now it's actually in a different place. If I mouse over this and if I zoom in again, you can see the location is <code>.NET/shared/Microsoft.NETCore.App</code> version 10. So the .NET Core runtime libraries, in a library called System.Linq.AsyncEnumerable — it's now built in.</p>
<p>And so you would think this would be just a slam dunk for us on the Rx maintenance team, but you would not believe how much work it has taken for us to step neatly out of the way of this development. Because the problem is: a lot of projects will have that reference to System.Linq.Async in them, and then they'll upgrade to .NET 10.</p>
<p>Let me show you what happens when you do that. If I install this again, if I put the library back — so now I'm on .NET 10 and I've got System.Linq.Async — and now I have a problem. It now says "the call is ambiguous between the following methods or properties." There are two implementations of LINQ for <code>IAsyncEnumerable</code>. There's the one that's built into the .NET runtime libraries, and there's the one that's in the System.Linq.Async package.</p>
<p>Now, you can fairly easily solve this by removing the reference to System.Linq.Async — unless you didn't add that reference in the first place. What if you are using some other library that depends on System.Linq.Async? Now you can't get rid of it. So that's fun.</p>
<p>So what we did is we actually released a new version of System.Linq.Async version seven. And if we update to that and now go back here, the problem goes away again. And essentially — if I mouse over again and zoom in — you can see we're back to using the one built into .NET Core, into the .NET runtime libraries.</p>
<p>So what essentially we've done is we've removed all the stuff that's now in the .NET runtime. So if you're on the latest version of our library, it no longer tries to provide you with LINQ for <code>IAsyncEnumerable</code>, because .NET does that for you.</p>
<p>However, it's more complicated than that, because what if you are using a library that was built against System.Linq.Async version six, and it's not been built against version seven? It's going to expect to find all of LINQ there in binary at runtime, because it's compiled against our DLLs and not the ones in the .NET runtime.</p>
<p>So we actually have to ship two completely different sets of binaries. If you look inside these NuGet packages, there are reference assemblies that say "oh no, we don't provide an implementation of LINQ for async anymore." But there are runtime assemblies that do continue to supply that for binary backwards compatibility. So it's all a bit hairy to make it work, but the net result is it should just work how you expect.</p>
<p>Basically, the only problem people are going to see, we hope — and so far this has panned out — the only problem we're expecting people to see is if they end up with a reference to System.Linq.Async version six and they upgrade to .NET 10. Or if someone else brings in this new runtime library, because you are allowed to add a reference to System.Linq.AsyncEnumerable even if you're on .NET 8 — they actually made it work down-level. If someone's done that to you, you end up with this ambiguous reference error. But you can fix it by just upgrading to the latest version of Ix.NET.</p>
<p>So we will eventually be deprecating the System.Linq.Async package. We're going to do that fairly soon. The only reason we haven't already done it is we wanted to make sure that this all worked for people. I had some sleepless nights when .NET 10 shipped, thinking "am I going to get a million bug reports because I've not thought of something and this is all going to go wrong?" But so far it seems to be fine.</p>
<p>So we are going to mark System.Linq.Async as deprecated so that people can stop using it eventually. But for the meantime, there's this kind of off-ramp where you just use this thing.</p>
<p>There's one other issue though, which is that there are some features we provided in System.Linq.Async that were not replicated in .NET 10. So for example, there's this slightly strange method called <code>AsAsyncEnumerable</code>, and there's an equivalent method in LINQ to Objects. The .NET runtime libraries do offer an <code>AsEnumerable</code> method — it basically says "I want to hide the concrete type of this thing and just turn it into the interface." It says "erase the type for me." I might have some <code>MyAsyncEnumerableImplementationType</code> and I'd like to treat it as an <code>IAsyncEnumerable</code> so that only the <code>IAsyncEnumerable</code> extension methods are available. It's occasionally useful to do this. And for some reason the .NET runtime library did not include that method when they did their own version of it.</p>
<p>So we continue to provide that, but it has moved. There is a new library. If you look in your dependencies, if you have a reference to the latest System.Linq.Async, you'll see that we have transitively given you a reference to System.Interactive.Async. Now this has always existed — this was always the library where we put non-standard LINQ-like things that aren't really proper LINQ, they're sort of extensions to LINQ. They live in this library for the async version. And there's also System.Interactive, which has existed for like 12 years — it's where we put non-standard but LINQ-like operators.</p>
<p>And so we've moved everything into there. So the idea is you would stop using System.Linq.Async — you would just remove your reference to that. If you need any of the functionality that we provide and .NET 10 did not copy over, you would instead add a reference to System.Interactive.Async, and then you're good.</p>
<p>So that was done back in November, and we'll be deprecating that library fairly soon. That is my not-very-brief answer to Cecil's excellent question, because I wanted to talk about that. People need to know.</p>
<p><strong>Frank Boucher:</strong> Interesting story. I never thought about the merging stuff — the impact of what it can cause.</p>
<p><strong>Ian:</strong> Well, I have an even bigger version of that that I want to talk about as well today, which is one of the big things that we're doing for Rx version seven.</p>
<p>So we shipped Rx 6, couple of years ago. We shipped Rx 6.1 earlier this year — it has some handful of minor new features in it, some additional operators and community contributions. But Rx v7.0 — we are trying to fix a problem I talked about two and a half years ago and that we haven't managed to fix yet. So let me talk about what this problem is.</p>
<p>I have another window open here. Let me get it onto the right monitor. Right, so — do we have a drum roll, Cam? Right, so this is a pretty simple WPF application. All I'm doing is creating an observable sequence. The nature of the sequence isn't terribly interesting — it just produces numbers at kind of randomly spaced intervals. It counts up, but it does so at a slightly lumpy speed. And then in my subscription, I'm just putting the output directly into a property of a control.</p>
<p>So if I actually run this — it just shows increasingly high numbers at slightly random intervals. Not very interesting. But the point of this is to illustrate one of the things you often have to do in user interface programs.</p>
<p>If I comment out a magic line, this will stop working. We've immediately hit an exception — <code>InvalidOperationException</code>. Let me close the live preview. The calling thread cannot access this object because a different thread owns it. If you've done much user interface programming, you will be familiar with this problem. Basically almost all user interface technologies require the UI to be updated from the right thread. So any window handle in Windows belongs to one particular thread, and most UI frameworks don't like it if you try and change something from any different thread.</p>
<p>Back in the day, Windows Forms version one used to just break weirdly when you did this — it didn't notice you'd got it wrong. It would just gradually melt its innards and would start to go wrong. Now it actually detects it and throws an exception, which is an improvement. But basically you've got to be on the right thread.</p>
<p>So in Rx we offer these helper mechanisms where you can say "okay, I do want to subscribe to this source, but actually I need to observe it on a particular context." I can't just take the raw notifications because I happen to know this source is going to deliver them to me on a thread that is not useful.</p>
<p>So if I add this <code>ObserveOnDispatcher</code>, what this says is: I know I'm in the WPF world, and so I want the dispatcher for whatever thread I'm on when this method runs — the current thread's dispatcher — to be captured. And anytime this source emits a value, I would like to basically redirect that back onto the user interface thread before I handle it.</p>
<p>So now if I run this — I'm going to stick a breakpoint here. So when it tries to raise the events, if we look at the thread that I'm on, you can see I'm on some sort of thread pool worker thread up there. So if this were to come straight through, it would not be the right thread to hit the UI. But if I now hit F5 and see — well, okay, now we've received that. What thread are we on now? Well, now we're on the main thread, because I told Rx that's what I need.</p>
<p>So the point here is that Rx offers integration with certain UI frameworks. We do this stuff for WPF and a few other helpers. We also have ones for Windows Forms — you can do <code>ObserveOnControl</code>. We also do ones for UWP, or indeed anything that uses the Windows Core Dispatcher. So we have <code>ObserveOnCoreDispatcher</code>.</p>
<p>Here's the problem. All of this today is built into the same library. If you want this, you just use the same System.Reactive NuGet package as you do for anything else. So if I were to look at this project, the only NuGet package reference that I've got there is the standard Rx one.</p>
<p>Why is that a problem? Well, in a way it's not a problem — in a way it simplifies things. It means I just said "Rx please," and if I happen to be using WPF, then I get the WPF features. They're just right there. The problem, however, is when you start doing things like AOT (ahead of time) compilation or self-contained deployment.</p>
<p>If you want to build this WPF app into a self-contained form where you don't have to pre-install the .NET runtime to use it — this has been a mode that's increasingly well supported in recent versions of .NET — if you want that to work, then the problem is that including Rx means that it will now ship a complete copy of WPF and Windows Forms with anything you build, whether or not you're using either of those frameworks.</p>
<p>So if, for example, you are only targeting .NET 10 with the Windows-specific TFM because you happen to want to call some API that's in there — let's say maybe you're writing a console app that wants access to the sensor framework that's available in the Windows API. Maybe you want to read orientation data and report that over the network. You don't have a UI, but the problem is: because you said "I want the Windows flavour," Rx goes "oh, well then you must want WPF and Windows Forms," and so your binaries get about 90 megabytes larger as a result of this. Which is not good.</p>
<p>The reason this was missed at the time is that back when the decision to unify everything into a single package was made, there was no such thing as WPF on .NET Core. That didn't come along till .NET Core 3.1, and this decision was taken earlier than that. You only had WPF if you were doing .NET Framework — classic .NET FX — and there was no way of doing a self-contained deployment if you were building a .NET Framework app. You had to install the .NET Framework on the target machine before you could install your app. And so this whole problem didn't arise.</p>
<p>Since they made this decision, it's now become a real problem. If you are targeting a Windows-specific target framework moniker and you include Rx, and you build a self-contained deployment of any kind, you now get 90 megabytes of unwanted stuff. If you turn on trimming, it goes down to a mere 60 megabytes, which I guess is slightly better, but it's still an awful lot.</p>
<p>So we wanted to fix this, and actually I said last time I was on, two and a half years ago, I said "we're trying to fix this, we haven't worked out how to yet." We now think we have worked out how to.</p>
<p>So if you go to the NuGet package manager — if you are using Rx, there is a preview of Rx 7 available on NuGet. And if we do this, it unbundles the UI framework support. So if you say "I want System.Reactive," you just get System.Reactive. We don't give you the WPF stuff anymore.</p>
<p>Now, actually the runtime binaries still have it, because we've had to do the same binary compatibility thing to make sure that anything built against older versions of Rx that was expecting everything to be there will still work. But we no longer declare a dependency on WPF. We no longer force your application to depend on WPF. And so this gets rid of this problem.</p>
<p>Now the obvious downside of this is that if you are building against this, it will now say "well, that method doesn't exist anymore." And that's fine — you just need to add the right library.</p>
<p>But what we've done is we've added an analyser that detects when you've done this and says "oh, you are trying to use <code>ObserveOnDispatcher</code>, and that used to be built into System.Reactive. Now it isn't." So we thought, rather than confusing people and them going "why is this method gone away?" — we're actually telling them "okay, you now need to add a reference to this package for this to continue working." So people at least get told what to do as part of the upgrade.</p>
<p>So it's like "oh, okay — add a reference to System.Reactive.WPF." Let's go and find that. System.Reactive.WPF — there it is. We install that, and now because we've actually asked for WPF, we're going to get it. And that's fine. If you ask for it, you get it. If you don't ask for it, you don't get it. That's the new model.</p>
<p>And so we are hopeful that this is a relatively painless way of getting past the problem. Because people that were not using Rx because of this — the Avalonia UI project abandoned Rx because their use of Rx meant that their binaries were 60 megabytes bigger than they needed, and they felt it was less painful just to stop using Rx than it was to force that on their users.</p>
<p>And so our goal is to say, well, we'd like them to come back. Ideally — maybe they never will. Maybe we've burnt that bridge. But we'd like Rx to be a good choice for anyone building Avalonia UI apps. And so we have to unbundle the UI elements. So that's the big change with Rx 7.</p>
<p>We've gone to great lengths to ensure binary compatibility is maintained, using similar tricks to the ones I just described for the System.Linq.Async stuff as well. It's currently in preview — we've had about 40,000 downloads so far. At some point we're going to have to pull the trigger. No one's told us it doesn't work yet. I would encourage people to try this and see if it works for them, because we suspect no one's told us it's not working because they haven't tried it.</p>
<p>We've done our utmost to test this in every way we can think of, and hopefully it's fine. But sooner or later — this year, not too many months from now — we will go for a proper release of this and then we'll find out whether it's as good as we think it is.</p>
<p>So that's the big thing coming in Rx 7. And we're basically making that the only feature of Rx 7, because we want to separate that change out from everything else. And then further feature work — there'll probably be an Rx version 8 fairly quickly on the heels of Rx 7, where we actually do new feature work.</p>
<p>So I can take a breath for a second.</p>
<p><strong>Frank:</strong> I want to congratulate the team for the clarity of all those error messages, because not everybody makes that effort, and all those messages were very clear and helpful and all those things. So I think this is a great effort. It looks like the team cares.</p>
<p><strong>Ian:</strong> We do really care. We've tried really hard to — oh, congrats on that. It's been controversial because there are a lot of people who would rather we just left it as it is. That's been quite a widespread opinion. But we know there are projects that have walked away from Rx because of this. And so our view is that that's not an acceptable solution.</p>
<p>I'm also just going to address the other thing people say, which is: "Well, can't you just tear it up and start again? Just build a new Rx. Just say it's the end of the line for System.Reactive. Just do System.Reactive.Two or System.MoreReactive or whatever it might be." Super Reactive.</p>
<p>That doesn't work. That absolutely does not work, because if you end up with dependencies on both those libraries — let's say you've decided to use the new Reactive, and then you take a dependency on some component that's using the old Reactive — now you get those ambiguous method errors again. The same problem I showed you with System.Linq.Async. Because you've now got two completely different implementations of Rx, both saying "I provide <code>Where</code> and <code>GroupBy</code> and <code>Select</code> and so on for <code>IObservable</code>." And the compiler doesn't know which one you want, and that's an absolute nightmare.</p>
<p>So the so-called "clean break" solution is no such thing. You basically have to fix this in System.Reactive if you're going to have any hope of proper compatibility going forward.</p>
<p>So there is a huge design document about this. If anyone wants to go into the details on this — let me try and find where that is. So if you go into the Reactive repo and look at ADR — Architectural Design Record — and look at the Package Split ADR, this is my attempt to summarise everything you need to understand in order to solve this problem correctly.</p>
<p>"Summarised."</p>
<p>Yes, it's not as simple as people think. So this explains everything that anyone has suggested to us, because we sought feedback from the community on "what are we going to do about this?" And we've evaluated every option and written the pros and cons about it and explained why it is we've chosen the solution we've eventually gone with. And we also explain exactly what the problem is as well. So you can see — that's how big a self-contained exe is: 90 megabytes. It grows to 182 megabytes if you add Rx. And that's just not acceptable, we don't think.</p>
<p><strong>Katie:</strong> So that's the big deal with Rx 7. That makes sense. I mean, even just looking at that document, I'm like, I believe you, Ian, I believe you fully. We do have a couple of questions that have come in over the last couple of minutes that I want to make sure we get to.</p>
<p><strong>Cam:</strong> Sure. This one from John came in a little bit ago. He says that he's a QA automation person and he's wondering where can we access TFS to log bugs?</p>
<p><strong>Ian:</strong> As in, if you find bugs in the System.Reactive library, the place to log bugs would be — it's a GitHub repo. So just go to github.com/dotnet/reactive/issues, and anyone can report issues. So I'm not sure if that's what he actually means, but we're not on TFS, we're on GitHub. Although we do use Azure DevOps to do our build, because that's what the .NET Foundation originally set us up with for this project. But that is the place to go to report bugs.</p>
<p><strong>Katie:</strong> Perfect. And I think Cam had sent a link to this repo earlier in the stream, so go ahead and search for that. And John, if that wasn't what you were asking, feel free to clarify and we'll ask again.</p>
<p>There is another question from MC Nets: is this Reactive library used by UNO Platform in MVUX, or did they write their own libraries?</p>
<p><strong>Ian:</strong> I don't know about Uno. I mean, it's possible to use this in Uno. One thing I would say though is that we know there are two — at least two — environments where we have some problems. One of which is browser WASM, and the other of which is Unity, the games development environment or the 3D development environment. And both of those are to do with differences in threading in that world.</p>
<p>And that's actually one of the things we're going to work on with Rx 8. We want to address the problems that mean that Rx has some issues in browser WASM and on Unity. And the thing about Uno is that it can end up running in the browser. So I would anticipate that it has the same problems there.</p>
<p>I also know that people have written — there've been various attempts to fork Rx and do new versions of it, because for quite a while it was basically unsupported, and so people were asking for features to be done and nothing happened for a couple of years, and so people went off and did their own forks. Quite understandably.</p>
<p>Our goal is really to try and make it good for all .NET applications, and so that's a big driver of Rx 8 — dealing with these things. I don't actually know specifically with Uno — they may well have done their own libraries. But our goal is to get to the point where they don't have to. And if they wanted to come back to the original Rx, they could. But equally, if they're happy with their new solution, then more power to them.</p>
<p><strong>Katie:</strong> Totally. That makes sense completely. I think that's all the questions I'm seeing. Frank, Cam, anything from you?</p>
<p><strong>Cam:</strong> No, I've got nothing. I just wanted to comment on the work that you guys have done recently to handle the integration with .NET 10 and those various cases of ambiguous references. I am very impressed with that. Ian, I thank you for all your work you've done on that. And I know we've got a lot of comments out in the chat about how useful Reactive has been for them, and I think I saw somebody refer to the entire Reactive team as "goated." So I think we'll take that as a compliment.</p>
<p><strong>Ian:</strong> High praise, high praise. So I should obviously give credit to the people who came before our involvement, because the original team at Microsoft and then the open source group that kind of carried it forwards — they eventually were unable to continue to put the work into it and so it became more abandoned for a bit. But it wouldn't be here today if it wasn't for those people. So there's been many, many people before our involvement, without whom it just wouldn't exist at all. So I can't take too much credit. I'm just trying to keep it available for the next generation, because I think I hugely admire the work that went into it before we got here and want that to continue to be available for everyone, because I think it's really good.</p>
<p><strong>Frank:</strong> Do we have time for a last question? I just see — MC Nets was asking on Twitch: is it only lists, is it only objects that we can observe, or can we look for any change in the class or something like that? How do I trigger a change if some of the properties change?</p>
<p><strong>Ian:</strong> Right. You want to look at a couple of projects out there. So there's a project called ReactiveUI, which is an Rx-based project. It uses Rx deeply to power a user interface-based framework, and that in itself depends on another library. I'm going to get the name wrong, so just go look at ReactiveUI and you'll find it that way.</p>
<p>There's basically a whole model for doing property changes integrated with Rx. So you can say "I've got this Rx stream, I'd like to present it through a property," or you can say "I've got this property that I update, I would like to turn that into an Rx stream." It's called something like Dependent Data, but that's not the right name — but the ReactiveUI library uses it. So that's kind of the best way in for that. So yes, absolutely you can do it.</p>
<p>There's a couple of things I just wanted to quickly talk about if we have a couple more minutes. Do we have time?</p>
<p><strong>Katie:</strong> We've got about five minutes.</p>
<p><strong>Ian:</strong> Okay. So I just wanted to say the other things we're aiming to do for the next version, so people know what we're working on. We want to make sure that we are usable for as many .NET applications as possible, and specifically we want to address WASM and Unity.</p>
<p>We also are going to improve the trimability support. So Rx 6 did make Rx trimmable, because it used to be: if you added a reference to Rx, that's one megabyte of extra stuff in your executable. In Rx 6, we added basic trimability, so it could chuck away most of Rx if you weren't using most of it. But we didn't do a complete job — we did just enough to be useful. We're going to do it properly with Rx 8.</p>
<p>The other thing we are going to come back to — we have been pushing this along in the background, but it's been slow for various reasons — is Async Rx. So just as you have <code>IAsyncEnumerable</code>, there is <code>IObservable</code>, but there's also <code>IAsyncObservable</code>. And that is a thing that the original Rx team kind of never really finished. It was always in prototype phase.</p>
<p>We did get a preview version of that out there on NuGet so you can use it today. I just wanted to explain to people why it's still in early preview. And the basic reason is we don't have a complete test suite for it yet.</p>
<p>What we are gradually doing is updating the way the Rx test suite works so we can have a single test suite that works across both regular Rx and Async Rx. And once we've got that, we'll then be happy that we're at production quality for both libraries. We want to test them to the same level and we can't do that yet. So until we're able to test Async Rx to the same extent that we do — because we have thousands of tests for proper Rx — until we can get those applied to everything, we're not happy to say that people should be using it. But we are still working on it. It's been slow because other things have taken priority and we really needed to fix this bloat issue. But that is coming. We're still working on it, for people who were wondering where it's gone.</p>
<p><strong>Katie:</strong> That makes sense. Thank you so much for all those updates, and thank you for being here today, Ian. This was an incredible show. You've gotten a lot of love in the chat and we super appreciate it. And thanks to everybody who is watching. We're here every Monday, same time, same place — On .NET Live. Next week we'll be here with Mattias. Super excited for that one as well. And I hope you have a great rest of your day or evening. Thank you very much.</p>]]></content:encoded>
    </item>
    <item>
      <title>T4 templates on modern .NET</title>
      <description>T4 is a .NET-based templating language. It used to target just .NET Framework. It is now possible to use modern .NET runtimes, but it requires additional work. This post shows how to get it working.</description>
      <link>https://endjin.com/blog/t4-templates-on-modern-dotnet-10</link>
      <guid isPermaLink="true">https://endjin.com/blog/t4-templates-on-modern-dotnet-10</guid>
      <pubDate>Wed, 18 Feb 2026 06:30:00 GMT</pubDate>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET</category>
      <category>dotnet</category>
      <category>T4</category>
      <category>Templates</category>
      <category>Code generation</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/12/t4-templates-on-modern-dotnet-10.png" />
      <dc:creator>Ian Griffiths</dc:creator>
      <content:encoded><![CDATA[<p>T4 is a popular .NET-based templating language. Originally, it could use only .NET Framework, but in 2023, <a href="https://devblogs.microsoft.com/dotnet/t4-command-line-tool-for-dotnet/">Microsoft added a version of the template tool that could use .NET 6.0</a>. At some later point they added support for .NET 8.0. (As I write this in February 2026, there was not yet support for .NET 10.0.)</p>
<p>However, this modern .NET support is minimal, and is not used by default. The Visual Studio integration continues to use the old .NET Framework implementation. To use the new modern .NET support, you have to run the command line tool manually, or adapt your project files to invoke the tool for you.</p>
<p>The <a href="https://github.com/dotnet/reactive">Rx.NET</a> and <a href="https://github.com/reaqtive/reaqtor/">Reaqtor</a> codebases make extensive use of T4. Up until now we've relied on the built-in Visual Studio support, but the inability to use modern .NET features is starting to become a problem. This post explains what it takes to move projects that use the old .NET Framework T4 support in Visual Studio over to using T4 with modern .NET.</p>
<h2 id="a-quick-introduction-to-t4">A quick introduction to T4</h2>
<p>The documentation seems coy about what the name T4 means, but some say it stands for Text Template Transformation Toolkit. If you've not used T4 before, it's a bit like a Razor page–it can contain a mixture of plain text and C# code. For example:</p>
<pre><code class="language-tt">&lt;#@ template language="C#" #&gt;
This is some plain text that will be emitted verbatim.
&lt;#
  // This code is executed, so it won't appear in the output, but it
  // changes how the output that follows is produced.
  for (int i = 0; i &lt; 5; ++i)
  {
#&gt;
This is also emitted. It's in a loop, so we get many copies.
&lt;#
    // This is another code block.
  }
#&gt;
</code></pre>
<p>If I put that in a file called <code>SimpleTemplate.tt</code> and then run this command:</p>
<pre><code class="language-ps1">TextTransformCore SimpleTemplate.tt
</code></pre>
<p>it produces a file called <code>SimpleTemplate.txt</code> with this content:</p>
<pre><code>This is some plain text that will be emitted verbatim.
This is also emitted. It's in a loop, so we get many copies.
This is also emitted. It's in a loop, so we get many copies.
This is also emitted. It's in a loop, so we get many copies.
This is also emitted. It's in a loop, so we get many copies.
This is also emitted. It's in a loop, so we get many copies.
</code></pre>
<p>I've made my template emit plain text in this example to clarify the fact that T4 is fundamentally text-oriented. You could use it to generate C#, F#, VB.NET, markdown, HTML, Cucumber specs, or, as in this case, just plain text containing natural language.</p>
<p>In Rx.NET and Reaqtor we use T4 to generate repetitive code. For example, the <code>Min</code> and <code>Max</code> operators have multiple versions of what are essentially the same code, just for different numeric types. (Since .NET 7, there has been a better way to solve this particular problem: it introduced of new ways of defining interfaces, and the associated <em>generic math</em> feature. However, we still target .NET Framework in Rx.NET, so we can't use that.) We also often use templates driven by reflection to generate code whose structure is determined by other code.</p>
<h3 id="arent-we-supposed-to-be-using-source-generators-now">Aren't we supposed to be using source generators now?</h3>
<p>In theory the introduction of <a href="https://devblogs.microsoft.com/dotnet/introducing-c-source-generators/">source generators</a> renders T4 unnecessary for the ways we use it in Rx.NET and Reaqtor. Now there is direct support in the .NET SDK for generating code at build time.</p>
<p>However, having written a couple of source generators I find them to be a major step up in complexity from T4.</p>
<p>They enable developers to create really useful tools. For example, our <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema">Corvus.JsonSchema</a> libraries offer <a href="https://www.nuget.org/packages/Corvus.Json.SourceGenerator">Corvus.Json.SourceGenerator</a>, which is now my go-to solution when I want to deal with JSON in C#. But while source generators can be great to use, they are a bit of a nightmare to write. So I think there is still a place for T4.</p>
<h2 id="tooling-changes">Tooling changes</h2>
<p>To understand how to migrate an existing project to using .NET in T4, it's important to understand the differences in tooling support for T4 on .NET FX and T4 on .NET.</p>
<h3 id="visual-studios-existing-support-for.net-fx">Visual Studio's existing support for .NET FX</h3>
<p>Visual Studio has offered support for T4 for many years. You enabled it by adding this to your project file:</p>
<pre><code class="language-xml">&lt;ItemGroup&gt;
    &lt;Service Include="{508349b6-6b84-4df5-91f0-309beebad82d}" /&gt;
&lt;/ItemGroup&gt;
</code></pre>
<p>You could then tell Visual Studio that certain source files were T4 templates, and normally you would also tell it about the association between the T4 template and its generated output, e.g.:</p>
<pre><code class="language-xml">  &lt;ItemGroup&gt;
    &lt;None
        Update="Example.tt"
        Generator="TextTemplatingFileGenerator"
        LastGenOutput="Example.cs"
        /&gt;

    &lt;Compile
        Update="Example.cs"
        DesignTime="True"
        AutoGen="True"
        DependentUpon="Example.tt"
        /&gt;
  &lt;/ItemGroup&gt;
</code></pre>
<p>The <code>&lt;None&gt;</code> element here sets the <code>Generator</code> attribute to <code>TextTemplatingFileGenerator</code>, and this makes Visual Studio offer a couple of additional options on the file's context menu in Solution Explorer:</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/12/t4-vs-context-menu.png" alt="Visual Studio T4 context menu showing the Run Custom Tool and Debug T4 Template options" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/12/t4-vs-context-menu.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/12/t4-vs-context-menu.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/12/t4-vs-context-menu.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/12/t4-vs-context-menu.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>Selecting <strong>Run Custom Tool</strong> causes the T4 template to execute, generating its output. The <strong>Debug T4 Template</strong> runs it in the debugger so you can step through the template code.</p>
<h3 id="t4-on.net">T4 on .NET</h3>
<p>The more recently added support for T4 on .NET provides one thing: the <code>TextTransformCore</code> command line tool. There is no Visual Studio integration. There is no supported way to tell Visual Studio to execute a template using .NET–VS (today) only offers the old .NET Framework-based T4 execution that has had for years.</p>
<p>So the new .NET support is all very bare bones. We get almost nothing compared to the support available when running a T4 template on .NET Framework. The old context menu items are still available, it's just that they can only invoke the old .NET Framework T4 tooling.</p>
<h4 id="changes-required-when-using-migrating-t4-from.net-framework-to.net">Changes required when using migrating T4 from .NET Framework to .NET</h4>
<p>Note that if your are using <code>assembly</code> directives in your template you might need to change them because some .NET runtime library types are in different assemblies. For example, if a template written to run on .NET FX includes this line:</p>
<pre><code class="language-tt">&lt;#@ assembly name="System.Core" #&gt;
</code></pre>
<p>you will probably need to change it to this to get it working on .NET:</p>
<pre><code class="language-tt">&lt;#@ assembly name="System.Linq" #&gt;
</code></pre>
<p>You might also find that you are getting errors such as these:</p>
<p><code>Compiling transformation: CS1069: The type name 'Stack&lt;&gt;' could not be found in the namespace 'System.Collections.Generic'. This type has been forwarded to assembly 'System.Collections, Version=0.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a' Consider adding a reference to that assembly.</code></p>
<p>You may need to add this:</p>
<pre><code class="language-tt">&lt;#@ assembly name="System.Collections" #&gt;
</code></pre>
<p>A more subtle problem is that the T4 tooling does not understand the distinction between reference assemblies and runtime assemblies. It always uses the latter, which can cause some surprises. For example, you might get an error of this form when trying to use the types in <code>System.Xml.Linq</code>:</p>
<p><code>error CS1069: Compiling transformation: CS1069: The type name 'XElement' could not be found in the namespace 'System.Xml.Linq'. This type has been forwarded to assembly 'System.Private.Xml.Linq, Version=8.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51' Consider adding a reference to that assembly.</code></p>
<p>You can resolve this by adding another assembly directive:</p>
<pre><code class="language-tt">&lt;#@ assembly name="System.Private.Xml.Linq" #&gt;
</code></pre>
<p>but this is somewhat unsatisfactory: the fact that .NET 8.0 happens to put this type in this assembly is an implementation detail that could easily change from one version of .NET to the next. But for now this seems to be the only way to work around this. I've submitted a bug report at <a href="https://developercommunity.visualstudio.com/t/TextTransformCore-uses-runtime-not-ref/11013312">https://developercommunity.visualstudio.com/t/TextTransformCore-uses-runtime-not-ref/11013312</a>? if you're having the same problem and want to add your support for this being fixed.</p>
<h2 id="better-project-support-for-t4-on.net">Better project support for T4 on .NET</h2>
<p>Although there is no built in tooling, it's actually relatively straightforward to make an existing project use the newer tooling, once you know how. We can do this with some modifications to project files. The basic process is:</p>
<ul>
<li>Define an <code>ItemGroup</code> for all your T4 templates</li>
<li>Automatically set the <code>DependentUpon</code> item metadata on the generated code (to ensure generated files go underneath their T4 files)</li>
<li>Define a custom <code>Target</code> that runs the T4 templates if the templates are newer than the generated outputs</li>
</ul>
<h3 id="defining-an-item-group-for-templates">Defining an item group for templates</h3>
<p>I put this in a <code>Directory.build.props</code> file at the root of my solution, so that <code>.tt</code> files anywhere in any project in my solution are added to the item group:</p>
<pre><code class="language-xml">&lt;ItemGroup&gt;
  &lt;TextTemplates Include="**\*.tt"&gt;
    &lt;GeneratedOutput&gt;%(Filename).cs&lt;/GeneratedOutput&gt;
    &lt;GeneratedOutputRelativePath&gt;%(RelativeDir)%(GeneratedOutput)&lt;/GeneratedOutputRelativePath&gt;
  &lt;/TextTemplates&gt;
&lt;/ItemGroup&gt;
</code></pre>
<p>The <code>Include="**\*.tt"</code> is a glob that adds all files with a <code>.tt</code> extension anywhere in any project to the <code>TextTemplates</code> item group.</p>
<p>We then set two item metadata values:</p>
<ul>
<li><code>GeneratedOutput</code>: the filename of the output that the template will generate</li>
<li><code>GeneratedOutputRelativePath</code>: the path of the template output relative to the project folder</li>
</ul>
<p>In fact, in the Reaqtor codebase, we do something slightly more complex:</p>
<pre><code class="language-xml">&lt;ItemGroup&gt;
  &lt;TextTemplates Include="**\*.tt"&gt;
    &lt;GeneratedOutput Condition="Exists('%(RootDir)%(Directory)%(Filename).generated.cs')"&gt;%(Filename).generated.cs&lt;/GeneratedOutput&gt;
    &lt;GeneratedOutput Condition="%(GeneratedOutput) == ''"&gt;%(Filename).cs&lt;/GeneratedOutput&gt;
    &lt;GeneratedOutputRelativePath&gt;%(RelativeDir)%(GeneratedOutput)&lt;/GeneratedOutputRelativePath&gt;
  &lt;/TextTemplates&gt;
&lt;/ItemGroup&gt;
</code></pre>
<p>Ths reason for this is that historically the Reaqtor codebase has used two different conventions. In some cases, the a template called, say, <code>ByteArray.tt</code> generates a file with the same name but a <code>.cs</code> template, e.g. <code>ByteArray.cs</code>. However, in some places the T4 includes this directive:</p>
<pre><code class="language-tt">&lt;#@ output extension=".generated.cs" #&gt;
</code></pre>
<p>For example, that appears in the <code>LetOptimizerTests.tt</code> template, and the effect is that the generated file is called <code>LetOptimizerTests.generated.cs</code>. (In this case, that's because the generated code is adding extra methods to a partial class, so there's already a non-generated <code>LetOptimizerTests.cs</code> file. The generated code needs to go into a file with a different name.) Just to confuse matters further, some templates have <code>Generated</code> in their name, e.g. <code>PooledObjects.Generated.tt</code>. Obviously in this case we <em>don't</em> want the generated file to be <code>PooledObjects.Generated.generated.cs</code>, so this one is really an example of the first convention in which the <code>.tt</code> becomes <code>.cs</code> in the generated output.</p>
<p>The more complex XML shown above takes this into account: it looks to see if a file with that <code>.generated.cs</code> extension exists, and if so, selects that filename as the target for the template. But if it's not present, it just picks the other name.</p>
<p>Note that it's actually the template itself that determines what the output file name is with that <code>output extension</code> directive. This project file content just looks at what files exist, and infers from that which convention was used.</p>
<h3 id="correct-solution-explorer-behaviour-with-dependentupon">Correct Solution Explorer behaviour with DependentUpon</h3>
<p>To ensure that the source file that a template generates  appears nested inside that template in Solution Explorer, I put this in the <code>Directory.Build.targets</code>:</p>
<pre><code class="language-xml">&lt;ItemGroup&gt;
  &lt;Compile Update="@(TextTemplates-&gt;'%(GeneratedOutputRelativePath)')"&gt;
    &lt;DesignTime&gt;true&lt;/DesignTime&gt;
    &lt;AutoGen&gt;true&lt;/AutoGen&gt;
    &lt;DependentUpon&gt;%(TextTemplates.Filename).tt&lt;/DependentUpon&gt;
  &lt;/Compile&gt;    
&lt;/ItemGroup&gt;
</code></pre>
<p>We put this in the <code>Directory.Build.targets</code> file so that it can run after everything in the .NET SDK's various <code>.props</code> files. Those will set up the <code>Compile</code> item group, which we just want to update. If we put this in the <code>Directory.Build.props</code> file, the <code>Compile</code> item group wouldn't exist yet so there would be nothing for us to <code>Update</code>.</p>
<p>With this in place, you can now remove all entries of this form from your project files:</p>
<pre><code class="language-xml">&lt;ItemGroup&gt;
  &lt;None
      Update="Example.tt"
      Generator="TextTemplatingFileGenerator"
      LastGenOutput="Example.cs"
      /&gt;
  &lt;Compile
      Update="Example.cs"
      DesignTime="True"
      AutoGen="True"
      DependentUpon="Example.tt"
      /&gt;
&lt;/ItemGroup&gt;
</code></pre>
<p>These are no longer necessary because the preceding <code>ItemGroup</code> automatically sets the item group metadata correctly for <em>all</em> templates.</p>
<h3 id="custom-target-to-execute-templates">Custom target to execute templates</h3>
<p>Finally, also in the <code>Directory.Build.targets</code> we define this custom target:</p>
<pre><code class="language-xml">&lt;Target
  Name="_TransformTextTemplates"
  BeforeTargets="PreBuildEvent"
  Condition="@(TextTemplates) != '' and $(DevEnvDir) != ''"
  Inputs="@(TextTemplates)"
  Outputs="@(TextTemplates-&gt;'%(GeneratedOutputRelativePath)')"&gt;

  &lt;Exec
    WorkingDirectory="$(ProjectDir)"
    Command='"$(DevEnvDir)TextTransformCore.exe" "%(TextTemplates.Identity)"' /&gt;

&lt;/Target&gt;
</code></pre>
<p>We've set this to execute before the <code>PreBuildEvent</code>, meaning that all T4 generation occurs before the main build work happens.</p>
<p>The <code>Condition</code> here ensures that this target only attempts to run when the build is in a Visual Studio environment. (Either we are using Visual Studio to run the build, or the build was run from a Visual Studio developer prompt.) This is necessary because the <code>TextTransformCore</code> tool is not part of the .NET SDK, so it's not universally available. It's part of Visual Studio.</p>
<p>Generated source files will be checked into source control, so we only ever need to run the T4 tool if the template changes. So in cases where someone just clones a repository and builds it, it won't matter if they don't have Visual Studio available because all the generated files will be present anyway. (But anyone wishing to modify a template, and to get the corresponding modified output, will need Visual Studio, because that's the only official way to get the <code>TextTransformCore</code> tool today.)</p>
<p>This target uses the <code>Inputs</code> and <code>Outputs</code> to ensure that it only runs templates when the <code>.tt</code> file's timestamp is newer than the generated source file. (This conditional timestamp-based execution is built into MSBuild. You just have to tell it how a target's inputs and outputs are related.)</p>
<h2 id="conclusions">Conclusions</h2>
<p>We are no longer constrained to using .NET Framework inside T4 files. Although this means abandoning the built-in Visual Studio tool support, with some relatively simple project file modifications, it's possible to get your T4 files using a modern .NET runtime in a straighforward way. We do lose the ability to debug the templates, but we get automated re-execution of the templates as part of the build.</p>]]></content:encoded>
    </item>
    <item>
      <title>What is Retrieval-Augmented Generation (RAG)?</title>
      <description>What is RAG? Learn how RAG combines retrieval, augmentation &amp; generation to ground GenAI responses in your data while reducing hallucinations &amp; improving accuracy.</description>
      <link>https://endjin.com/blog/what-is-retrieval-augmented-generation-rag</link>
      <guid isPermaLink="true">https://endjin.com/blog/what-is-retrieval-augmented-generation-rag</guid>
      <pubDate>Thu, 05 Feb 2026 07:30:00 GMT</pubDate>
      <category>AI</category>
      <category>GenAI</category>
      <category>RAG</category>
      <category>Azure Foundry</category>
      <category>Azure AI Search</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/01/what-is-retrieval-augmented-generation-rag.png" />
      <dc:creator>Carmel Eve</dc:creator>
      <content:encoded><![CDATA[<p>There has been a massive explosion in the use of generative AI, and when and <em>how</em> we use it has become an incredibly important question. It is easy enough to throw anything into an LLM, and a lot of those attempts will be met with mixed success.</p>
<p>One thing that LLMs are <em>provably</em> good for is the summarisation / re-structuring of text. But, how do we focus the model down to the information we care about - and stop the tendency of adding additional, plausible, but possibly irrelevant (or incorrect!) information? And how do we limit the information it needs to parse so that we don’t reach token limits or performance degradation due to huge inputs? This is where RAG comes in.</p>
<h2 id="what-is-rag">What is RAG?</h2>
<p>Retrieval-Augmented Generation (RAG) is a multi-step process by which we retrieve relevant information, and then add that information to the context, along with the given prompt. It allows us to ground responses in our data, rather than relying solely on pre-trained knowledge.</p>
<p>The RAG process is as follows:</p>
<ul>
<li><strong>Retrieval</strong>: Retrieve relevant information. This can be from databases, documents, knowledge bases, etc. Often semantic / vector-based search is used to find information relevant to the input (more on this later).</li>
<li><strong>Augmentation</strong>: The retrieved information is then added into the context, augmenting the prompt with the relevant data.</li>
<li><strong>Generation</strong>: The language model generates a response, based on the augmented prompt.</li>
</ul>
<p>This approach has a few advantages:</p>
<ul>
<li>Information is up to date as it can be retrieved from a live source, rather than relying on what the model was trained on.</li>
<li>You can ground responses in your domain-specific knowledge, without needing a specialised model.</li>
<li>You can add references to specific documents / pieces of information, allowing you to cross-check the response.</li>
<li>By enforcing direct links to the data, you can reduce the chance of hallucinations (though with large caveats - the subject of a future post).</li>
<li>You do not need to release sensitive information into model training processes.</li>
<li>You can control access like you would to any databases, and only ever add information that a user is allowed to see into the context. This allows for a fine-grained security model that would be impossible if training the LLM on all of the data (in this case, there is no way to limit what data a user has access to, if they are given access to the LLM).</li>
<li>Reducing the amount of information that the model needs to process (by limiting the context to the most relevant information) means that you can usually use smaller models and still achieve great results.</li>
</ul>
<p>Let's take an example of a retail website where customers can leave reviews. You might want to build an application that allows users to ask questions about the reviews that customers have left.</p>
<p>In a RAG example, the review data could be queried (by standard query, or vector search), and the reviews that are relevant would be <strong>retrieved</strong>. These reviews would then be used to <strong>augment</strong> the user's question, and added to the context that is passed into an LLM. The LLM would then use that context to <strong>generate</strong> an answer to the user's question.</p>
<h2 id="retrieval">Retrieval</h2>
<p>At the heart of RAG is the ability to retrieve content that is relevant.</p>
<h3 id="database-query">Database Query</h3>
<p>This could take the form of a standard database query based on criteria. In our review example, imagine that these reviews are stored in a database. The user could filter the reviews using certain criteria - "Clothing Product", "Fewer than 2 stars", "From the Last 24 Hours", etc.</p>
<p>These criteria could be used to query the database, and all relevant information returned. This information would then be added as context to any questions that the user wanted to ask.</p>
<h3 id="keyword-search">Keyword Search</h3>
<p>Another option is to filter the responses based on keywords. In our example, the user could input keywords ("late", "expensive", "broken", etc.). In this way, you can filter the results down to those which directly talk about relevant topics.</p>
<p>There are services, such as Azure AI Search, which allow you to do fast keyword matching on documents. Azure AI Search also allows you to do "fuzzy" matching - which handles differences in capitalisation, spelling mistakes, etc.</p>
<p>Again, the relevant documents would be added to the context used to augment the user's questions.</p>
<h3 id="vector-search">Vector Search</h3>
<p>In vector search, embeddings are used to find documents which are conceptually related to the search terms or questions asked.</p>
<p>An embedding is a numerical representation of text - essentially converting words and sentences into arrays of numbers (vectors) that capture their semantic meaning. These vectors contain many dimensions, allowing for complex concepts to be represented. Embedding models are typically neural networks trained on massive amounts of text to learn these relationships.</p>
<p>The secret to vector search is that, using these embeddings, semantically similar text ends up with vectors that are close together in this high-dimensional space. For example, imagine we had the words "snail" and "slug" and vectorised them, you might end up with two vectors that point in a similar direction.</p>
<p>Though similar, these two words don't mean <em>quite</em> the same thing, and this is captured in vector space:</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/12/vector-space.png" alt="An set of x,y,z axes, showing arrows for the vectors for snail and slug, and an arrow between them which is labelled &quot;shell&quot;." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/12/vector-space.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/12/vector-space.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/12/vector-space.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/12/vector-space.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>Here we can see that the vectors for snail and slug are pointing in relatively similar directions, and the difference between the two is about equal to the vector for "shell".</p>
<p>This is a super simplified example, and you would obviously need a lot more dimensions to represent all of the complex information that makes up a "slug" or "snail". But using this, we can start to visualise how different information and the connections between it can be represented in vector space.</p>
<p>An important thing to understand here is that AI embeddings turn words into vectors <em>in context</em>. For example, the word "bow" in the sentence "she had a <strong>bow</strong> in her hair", would have a very different vector to "the actors took a <strong>bow</strong>". Embedding models don't really embed the meaning of just a word, but instead the meaning of a sentence or context around a word is represented in vector space.</p>
<p>In practice, in RAG scenarios, generally it is not a single word that is vectorised, but a whole sentence or block of text. The overall meaning of the text is then used to retrieve relevant results.</p>
<p>Going back to our retail example, when you embed the text "Shipping took forever" and "Delivery was extremely slow", they'll have similar vector representations despite different words, because they mean similar things. Meanwhile, "Material feels cheap and flimsy" will be far away in vector space.</p>
<p>The user might ask a question such as "What are customers saying about delivery?". Using vector search, the user's question (or prompt) can be vectorised, and used to find reviews that are relevant to what they're asking about.</p>
<h2 id="augmentation">Augmentation</h2>
<p>Once relevant documents (and in the case of our example, reviews) have been retrieved, they are added to the context for the LLM. This usually involves literally adding the relevant documents into the prompt that is sent to the LLM.</p>
<p>For example, if a user asks "What are customers saying about delivery?", relevant reviews could be retrieved (using one of the methods above).</p>
<p>Then, the prompt sent into the LLM would include the user's question, and all of the relevant documents. E.g.:</p>
<div class="aside"><p>Answer the following question: 'What are customers saying about delivery?', based on the customer reviews in the context provided. Do not use any information outside of what is contained in the given context.</p>
<h3 id="context">Context:</h3>
<p><strong>Review 1</strong></p>
<ul>
<li>Rating: 5</li>
<li>Review Content: Amazing service! My order arrived in just 2 days, even though I only selected standard shipping. Very impressed.</li>
</ul>
<p><strong>Review 2</strong></p>
<ul>
<li>Rating: 1</li>
<li>Review Content: Shipping took over 3 weeks. No updates on tracking. Had to contact support multiple times. Completely unacceptable.</li>
</ul>
<p><strong>Review 3</strong></p>
<ul>
<li>Rating: 4</li>
<li>Review Content: Package arrived on time and in perfect condition. Tracking updates were accurate throughout the entire process.</li>
</ul>
<p><strong>Review 4</strong></p>
<ul>
<li>Rating: 3</li>
<li>Review Content: Delivery was supposed to be 5-7 days but took 10. Not terrible but not what was promised either.</li>
</ul>
<p><strong>Review 5</strong></p>
<ul>
<li>Rating: 1</li>
<li>Review Content: My package was marked as delivered but I never received it. Driver must have left it at the wrong address. Still waiting for resolution.</li>
</ul>
<p><strong>Review 6</strong></p>
<ul>
<li>Rating: 5</li>
<li>Review Content: Super fast shipping! Ordered on Monday and it was at my door by Wednesday morning. Packaging was secure too.</li>
</ul>
<p><strong>Review 7</strong></p>
<ul>
<li>Rating: 2</li>
<li>Review Content: Delivery took forever and the box was crushed when it finally arrived. Thankfully the product inside wasn't damaged.</li>
</ul>
</div>
<p>It is worth noting that the retrieval of relevant information, rather than just augmenting the prompt with <em>all</em> review data, is an important step. This is because as prompt input sizes increase, the responses from the LLM degrade. Limiting the context to a smaller subset of relevant information is the best way to get useful responses.</p>
<h2 id="generation">Generation</h2>
<p>The prompt will then be passed into an LLM. The LLM does not need to have been trained on any of the review data, as all of the relevant data is provided as part of the prompt.</p>
<p>The LLM will analyze the retrieved reviews and generate a natural language response. For our example question "What are customers saying about delivery?", the LLM might respond:</p>
<div class="aside"><p>"Customer feedback on delivery is mixed. Positive reviews highlight fast shipping times, with some customers receiving orders in 2-3 days even with standard shipping. The tracking system is generally praised for being accurate. However, there are concerns about inconsistent delivery times - some orders took significantly longer than promised (10+ days instead of 5-7). There are also reports of damaged packages and delivery issues like packages being marked as delivered but not received."</p>
</div>
<p>This response is grounded entirely in the context provided - the LLM hasn't hallucinated information or drawn on training data that might be outdated or irrelevant to this specific business.</p>
<p>An important thing to note is that though using RAG will increase the likelihood of responses being grounded and relevant (and adding sentences to the prompt such as "Do not use any information outside of what is contained in the given context." will further increase this likelihood), there is still an inherent propensity for LLMs to fall back on training data, or include unexpected information in responses. As such, even in RAG situations, all responses <em>must</em> be validated against expected outputs.</p>
<h2 id="summary">Summary</h2>
<p>RAG provides a powerful way to make LLMs more useful for real-world applications. By retrieving relevant information, augmenting prompts with that context, and then generating responses, we can:</p>
<ul>
<li>Keep information current and accurate</li>
<li>Ground responses in domain-specific data</li>
<li>Reduce hallucinations (with caveats)</li>
<li>Maintain security and access controls</li>
<li>Use smaller, more efficient models</li>
</ul>
<p>Thanks for reading this introduction to RAG search. Watch out for my next blog, which will dive deeper into implementation!</p>]]></content:encoded>
    </item>
    <item>
      <title>Polars Workloads on Microsoft Fabric</title>
      <description>Polars now ships inside Microsoft Fabric by default. Here's how to use it alongside Fabric's other analytics tools and what that means for your data workflows.</description>
      <link>https://endjin.com/blog/polars-workloads-on-microsoft-fabric</link>
      <guid isPermaLink="true">https://endjin.com/blog/polars-workloads-on-microsoft-fabric</guid>
      <pubDate>Thu, 29 Jan 2026 05:34:00 GMT</pubDate>
      <category>Polars</category>
      <category>Microsoft Fabric</category>
      <category>Deltalake</category>
      <category>DataFrame</category>
      <category>Cloud Computing</category>
      <category>Data Processing</category>
      <category>Python</category>
      <category>Microsoft Fabric</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/01/polars-workloads-on-fabric.png" />
      <dc:creator>Barry Smart</dc:creator>
      <content:encoded><![CDATA[<p>TL;DR: Run fast, cost-effective analytics on Microsoft Fabric without Spark clusters by using Polars. This guide covers reading from OneLake, transforming data with lazy evaluation, writing to Delta tables, and seamlessly switching between local and Fabric environments.</p>
<h2 id="overview">Overview</h2>
<p>Microsoft Fabric's Python Notebooks provide an ideal environment for running Polars-based analytics workloads. With Polars pre-installed and native access to OneLake, you can build fast, memory-efficient data pipelines without the overhead of Spark. This post walks through the practicalities: reading raw files, transforming data, and writing to Delta tables in your Lakehouse.</p>
<p>Key points:</p>
<ul>
<li><strong>Reading files</strong>: Use relative paths (<code>/lakehouse/default/Files/...</code>) for the pinned lakehouse, or ABFS paths for cross-workspace access</li>
<li><strong>Reading Delta</strong>: <code>pl.read_delta()</code> or <code>pl.scan_delta()</code> for lazy evaluation</li>
<li><strong>Writing Delta</strong>: <code>df.write_delta()</code> works out of the box; use <code>dt.replace_time_zone("UTC")</code> on timestamps to avoid SQL endpoint errors</li>
<li><strong>Storage options</strong>: Only needed for cross-lakehouse access—pass <code>{"bearer_token": notebookutils.credentials.getToken('storage'), "use_fabric_endpoint": "true"}</code></li>
<li><strong>Performance</strong>: Use <code>scan_*</code> methods for large files, specify columns upfront, and consider tuning rowgroups (8M+ rows) for DirectLake consumption</li>
<li><strong>Limitations</strong>: No V-ORDER or Liquid Clustering without Spark; max 64 vCores on single node</li>
</ul>
<h2 id="why-polars-on-fabric">Why Polars on Fabric?</h2>
<p>Fabric's new Python Notebooks run on a lightweight single-node container (2 vCores, 16GB RAM by default) rather than a Spark cluster. This is a better fit for many workloads:</p>
<ul>
<li><strong>Speed without complexity</strong>: Polars' Rust-based engine delivers Spark-comparable performance on datasets that fit in memory, without the cluster coordination overhead.</li>
<li><strong>Cost efficiency</strong>: No Spark cluster spin-up means lower CU consumption for smaller jobs.</li>
<li><strong>Rapid iteration</strong>: Sub-second notebook startup times versus minutes for Spark.</li>
<li><strong>Seamless integration</strong>: Polars is pre-installed and OneLake paths work out of the box.</li>
</ul>
<p>Microsoft explicitly recommends Polars (alongside DuckDB) as an alternative to pandas when you encounter memory pressure—a tacit acknowledgement that single-node, in-process tools have earned their place in the enterprise data stack.</p>
<h2 id="writing-code-that-can-run-both-locally-and-on-fabric">Writing code that can run both locally and on Fabric</h2>
<p>One of the major benefits that we find in using Polars is that we can develop locally (using local compute and local storage) and then deploy onto Fabric for fully hosted, production scale, automated operations.</p>
<p>This gives us the best of both worlds: a developer experience that feels like mainstream software engineering (fast inner dev loop with local unit tests which run in seconds), and the ability to deploy onto a cloud platform for orchestration and integration into the wider enterprise data pipeline ecosystem.</p>
<p>But in order to do this, we need to set up a simple helper function to detect where the code is running and set up the connections accordingly.</p>
<p>We tackle this in a few stages.</p>
<p>Firstly we need to detect if the code is running in a <strong>Fabric Python Notebook</strong>, we can determine that by checking for specific environment variables as follows:</p>
<pre><code class="language-python">import os

def is_fabric_python_notebook() -&gt; bool:
    """Detect specifically a Python (non-Spark) notebook."""
    return (
        'JUPYTER_SERVER_HOME' in os.environ 
        and 'SPARK_HOME' not in os.environ
    )
    
logger.info(f"Is this running in a Fabric Python Notebook?: {is_fabric_python_notebook()}")
</code></pre>
<pre><code class="language-plaintext">INFO: Is this running in a Fabric Python Notebook?: False
</code></pre>
<p>Next we need to be able to construct an <code>abfss</code> (Azure Blob File System Secure) path to files / folders that we want to be able to read from or write to using Polars.</p>
<p>The format of an <code>abfss</code> path adopts the following convention on Fabric:</p>
<p><code>abfss://{ws}@onelake.dfs.fabric.microsoft.com/{lh}.Lakehouse</code></p>
<p>Where <code>{ws}</code> is replaced by the Fabric workspace name and <code>{lh}</code> is replaced by the lakehouse name.</p>
<p>Furthermore, Fabric lakehouses are organised into two discrete areas:</p>
<ul>
<li><strong>Files</strong> - an area which is used to hold raw or unstructured content.  It has an <code>abfss</code> path:
<code>abfss://{ws}@onelake.dfs.fabric.microsoft.com/{lh}.Lakehouse/Files/{relative_path}</code></li>
<li><strong>Tables</strong> - an arae which holds tabular data (most commonly in Delta format).  It uses an <code>abfss</code> path convention of:
<code>abfss://{ws}@onelake.dfs.fabric.microsoft.com/{lh}.Lakehouse/Tables/{schema_name}/{table_name}</code></li>
</ul>
<p>There is also an option to "pin" a default lakehouse to a Fabric notebook and reference that in a shorthand path as follows:</p>
<ul>
<li>Files: <code>/lakehouse/default/Files/{relative_path}</code></li>
<li>Tables: <code>/lakehouse/default/Tables/{schema_name}/{table_name}</code></li>
</ul>
<p>It can be convenient to use this pinned default lakehouse for exploratory data analysis in notebooks.  However for software that is destined to end up in production, we recommend using the full <code>abfss</code> path to explicitly reference the lakehouse.</p>
<p>Furthermore, you can only pin one lakehouse to a notebook, this doesn't work well with the common pattern we see where a notebook is reading from one lakehouse (e.g. "Bronze"), wrangling the data and writing out to another lakehouse (e.g. "Silver").  For this reason, it makes sense to declare the full abfss path for both the sync and target lakehouses just to keep things consistent.</p>
<p>So it is often useful to use a Python helper function to construct the <code>abfss</code> path from component parts:</p>
<pre><code class="language-python">def construct_base_abfss_path(workspace_name: str, lakehouse_name: str) -&gt; str:
    """Construct the base ABFSS path for a given workspace and lakehouse."""
    # Because it is a URL, replace spaces with %20
    workspace_name = workspace_name.replace(" ", "%20")
    lakehouse_name = lakehouse_name.replace(" ", "%20")
    return f"abfss://{workspace_name}@onelake.dfs.fabric.microsoft.com/{lakehouse_name}.Lakehouse"
</code></pre>
<pre><code class="language-python">construct_base_abfss_path(workspace_name="polars_demo_workspace", lakehouse_name="polars_demo_lakehouse")
</code></pre>
<pre><code class="language-plaintext">'abfss://polars_demo_workspace@onelake.dfs.fabric.microsoft.com/polars_demo_lakehouse.Lakehouse'
</code></pre>
<p>Finally, we need to pass the "storage options" information to Polars to enable it to read from or write to a Fabric lakehouse.</p>
<p>The storage_options parameter is a dictionary which needs to contain two named elements:</p>
<ul>
<li>bearer_token - that will enable Polars to authenticate with the Fabric lakehouse API</li>
<li>use_fabric_endpoint - set to value "true" to tell Polars to leverage the fabric endpoint</li>
</ul>
<p>The <code>notebookutils</code> Python package is installed in the Python environment used by Fabric notebooks.  This enables you to retrieve the bearer token.</p>
<pre><code class="language-python">import notebookutils
storage_options = {
    "bearer_token": notebookutils.credentials.getToken('storage'),
    "use_fabric_endpoint": "true"
}

df.scan_csv(
    f"abfss://polars_demo_workspace@onelake.dfs.fabric.microsoft.com/polars_demo_lakehouse.Lakehouse/Files/land_registry_data/*.csv",
    storage_options=storage_options
)
</code></pre>
<p>Putting this all together, we can now set up the paths we will read from and write to using Polars dynamically based on whether we are running the notebook locally or on Fabric:</p>
<ul>
<li>Detect if we are running the notebook in Fabric (specifcally testing to see if it is a Python notebook)</li>
<li>Build the base path:
<ul>
<li>An <code>abfss</code> path if we are running on Fabric</li>
<li>A standard file path if we are running locally</li>
</ul>
</li>
<li>Additionally, if running on Fabric, import and leverage the <code>notebookutils</code> package to authenticate and generate a token that will enable connection to the lakehouses (provided we have permissions to do so)</li>
<li>Construct paths as required for source(s) and target(s) - in this case, we are keeping things simple:
<ul>
<li>We are reading from and writing to the same workspace / lakehouse</li>
<li>We are reading from one source (a folder containing *.csv files)</li>
<li>We are writing to a three target tables: house_prices, dates and locations</li>
</ul>
</li>
</ul>
<pre><code class="language-python">class FabricPaths:
    
    def __init__(self, workspace_name: str, lakehouse_name: str, local_base_path: str = "data/fabric"):
        self.workspace_name = workspace_name
        self.lakehouse_name = lakehouse_name
        self.local_base_path = local_base_path
        self.is_fabric = FabricPaths._is_fabric_python_notebook()
        
        if self.is_fabric:
            import notebookutils  # This Python package is only available on Fabric, so we need to import it conditionally.
    
    def generate_file_path(self, relative_path: str) -&gt; str:
        """Generate a full file path for the given folder type and name."""
        base_path = self._construct_base_abfss_path()
        return f"{self._get_base_path()}/Files/{relative_path}"
    
    def generate_table_path(self, schema_name: str, table_name: str) -&gt; str:
        """Generate a full table path for the given schema and table name."""
        base_path = self._construct_base_abfss_path()
        return f"{self._get_base_path()}/Tables/{schema_name}/{table_name}"
    
    def get_storage_options(self):
        """Get storage options for accessing Fabric storage."""
        if self.is_fabric:
            storage_options = {
                "bearer_token": notebookutils.credentials.getToken('storage'),
                "use_fabric_endpoint": "true"
            }
        else:
            storage_options = {}
        return storage_options
    
    def _get_base_path(self) -&gt; str:
        """Get the appropriate base path depending on the environment."""
        if self.is_fabric:
            return self._construct_base_abfss_path()
        else:
            return self.local_base_path
    
    @staticmethod
    def _is_fabric_python_notebook() -&gt; bool:
        """Detect specifically a Python (non-Spark) notebook."""
        return (
            'JUPYTER_SERVER_HOME' in os.environ 
            and 'SPARK_HOME' not in os.environ
        )
    
    def _construct_base_abfss_path(self) -&gt; str:
        """Construct the base ABFSS path for a given workspace and lakehouse."""
        # Because it is a URL, replace spaces with %20
        workspace_name = self.workspace_name.replace(" ", "%20")
        lakehouse_name = self.lakehouse_name.replace(" ", "%20")
        return f"abfss://{workspace_name}@onelake.dfs.fabric.microsoft.com/{lakehouse_name}.Lakehouse"
</code></pre>
<pre><code class="language-python"># Now use this class to generate paths

# We only need one class because we are working within a single Fabric workspace and lakehouse
fabric_paths = FabricPaths(
    workspace_name="polars_demo_workspace",
    lakehouse_name="polars_demo_lakehouse",
    local_base_path="../data/fabric"
    )

# Generate paths
raw_data_download_path = fabric_paths.generate_file_path("land_registry_data")
logger.info(f"Path to download CSV files into: {raw_data_download_path}")

source_path = fabric_paths.generate_file_path("land_registry_data/*.csv")
logger.info(f"Glob path to read all CSV files: {source_path}")

target_path_prices = fabric_paths.generate_table_path("house_price_analytics", "prices")
logger.info(f"Target table path for price fact table: {target_path_prices}")

target_path_dates = fabric_paths.generate_table_path("house_price_analytics", "dates")
logger.info(f"Target table path for date dimension table: {target_path_dates}")

target_path_locations = fabric_paths.generate_table_path("house_price_analytics", "locations")
logger.info(f"Target table path for location dimension table: {target_path_locations}")

storage_options = fabric_paths.get_storage_options()
logger.info(f"Storage options for accessing Fabric storage: {storage_options}")
</code></pre>
<p>When running locally, this generates the following log:</p>
<pre><code class="language-plaintext">INFO: Path to download CSV files into: ../data/fabric/Files/land_registry_data
INFO: Glob path to read all CSV files: ../data/fabric/Files/land_registry_data/*.csv
INFO: Target table path for price fact table: ../data/fabric/Tables/house_price_analytics/prices
INFO: Target table path for date dimension table: ../data/fabric/Tables/house_price_analytics/dates
INFO: Target table path for location dimension table: ../data/fabric/Tables/house_price_analytics/locations
INFO: Storage options for accessing Fabric storage: {}
</code></pre>
<p>When running in a Fabric Python Notebook, it generates the following log:</p>
<pre><code class="language-plaintext">Path to download CSV files into: abfss://polars_demo_workspace@onelake.dfs.fabric.microsoft.com/polars_demo_lakehouse.Lakehouse/Files/land_registry_data
Glob path to read all CSV files: abfss://polars_demo_workspace@onelake.dfs.fabric.microsoft.com/polars_demo_lakehouse.Lakehouse/Files/land_registry_data/*.csv
Target table path for price fact table: abfss://polars_demo_workspace@onelake.dfs.fabric.microsoft.com/polars_demo_lakehouse.Lakehouse/Tables/house_price_analytics/prices
Target table path for date dimension table: abfss://polars_demo_workspace@onelake.dfs.fabric.microsoft.com/polars_demo_lakehouse.Lakehouse/Tables/house_price_analytics/dates
Target table path for location dimension table: abfss://polars_demo_workspace@onelake.dfs.fabric.microsoft.com/polars_demo_lakehouse.Lakehouse/Tables/house_price_analytics/locations
Storage options for accessing Fabric storage: {'bearer_token': '[REDACTED]', 'use_fabric_endpoint': 'true'}
</code></pre>
<p>That's it!  The rest of the code is identical for both environments: we use the helper class above to take care of the only things that need to change: how the path is formed and setting up the <code>storage_options</code> for connecting in OneLake.</p>
<p>This class can become more sophisticated, for example:</p>
<ul>
<li>Adding a third option: run code locally, but connect to Fabric lakehouse for reading and writing data</li>
<li>Handling for default pinned lakehouses</li>
<li>Checking the workspace and lakehouse specified exist by calling Fabric APIs</li>
<li>Wrapping the logic above into a package and deploying it on Fabric so it is available across all notebooks</li>
</ul>
<p>But we have kept it simple in this case to illustrate the key concepts.</p>
<h2 id="download-data">Download data</h2>
<p>To support this use case, we are going to download some open data prime the "files" area with raw data we can analyse.</p>
<p>The are sourcing this from the <a href="https://www.gov.uk/government/statistical-data-sets/price-paid-data-downloads">UK Land Registry House Price Data open data repository</a>.</p>
<p>Data is available for us under an <a href="https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/">Open Government Licence</a>.</p>
<pre><code class="language-python">import requests
import fsspec

HOUSE_PRICE_BASE_URL = "http://prod.publicdata.landregistry.gov.uk.s3-website-eu-west-1.amazonaws.com/"

# Each file is approximately 100MB in size.  Change the number of years to control the total data size.
NUMBER_OF_YEARS = 3

list_of_files = [f"pp-{year}.csv" for year in range(2025, 2025 - NUMBER_OF_YEARS, -1)]

for file_name in list_of_files:
  
    remote_file_url = f"{HOUSE_PRICE_BASE_URL}{file_name}"
    path_to_save_file = raw_data_download_path + "/" + file_name

    # Download the CSV file with streaming enabled to avoid OOM on limited memory
    with requests.get(remote_file_url, stream=True) as response:
        response.raise_for_status()  # Ensure we notice bad responses

        # fsspec automatically handles the protocol (file:// versus abfss://) based on the source_path
        with fsspec.open(path_to_save_file, mode='wb', **storage_options) as f:
            # Write in 1MB chunks
            for chunk in response.iter_content(chunk_size=1024*1024):
                f.write(chunk)

    logger.info(f"Downloaded {file_name} to: {path_to_save_file}")
</code></pre>
<pre><code class="language-plaintext">INFO: Downloaded pp-2025.csv to: ../data/fabric/Files/land_registry_data/pp-2025.csv
INFO: Downloaded pp-2024.csv to: ../data/fabric/Files/land_registry_data/pp-2024.csv
INFO: Downloaded pp-2023.csv to: ../data/fabric/Files/land_registry_data/pp-2023.csv
</code></pre>
<h2 id="reading-files">Reading files</h2>
<p>When you create a new Python Notebook in Fabric you get immediate access to:</p>
<ul>
<li>Polars (currently v1.6 in the default environment)</li>
<li>The <code>delta-rs</code> library for Delta Lake operations</li>
</ul>
<p>You can use all of the common Polars functions to read files from a Fabric lakehouse both eager and lazy versions:</p>
<table>
<thead>
<tr>
<th style="text-align: left;">Format</th>
<th style="text-align: left;">Eager Read</th>
<th style="text-align: left;">Lazy Read</th>
<th style="text-align: left;">Eager Write</th>
<th style="text-align: left;">Lazy Write</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;">CSV</td>
<td style="text-align: left;"><code>pl.read_csv()</code></td>
<td style="text-align: left;"><code>pl.scan_csv()</code></td>
<td style="text-align: left;"><code>df.write_csv()</code></td>
<td style="text-align: left;"><code>lf.sink_csv()</code></td>
</tr>
<tr>
<td style="text-align: left;">Excel</td>
<td style="text-align: left;"><code>pl.read_excel()</code></td>
<td style="text-align: left;">❌</td>
<td style="text-align: left;"><code>df.write_excel()</code></td>
<td style="text-align: left;">❌</td>
</tr>
<tr>
<td style="text-align: left;">Parquet</td>
<td style="text-align: left;"><code>pl.read_parquet()</code></td>
<td style="text-align: left;"><code>pl.scan_parquet()</code></td>
<td style="text-align: left;"><code>df.write_parquet()</code></td>
<td style="text-align: left;"><code>lf.sink_parquet()</code></td>
</tr>
<tr>
<td style="text-align: left;">JSON</td>
<td style="text-align: left;"><code>pl.read_json()</code></td>
<td style="text-align: left;">❌</td>
<td style="text-align: left;"><code>df.write_json()</code></td>
<td style="text-align: left;">❌</td>
</tr>
<tr>
<td style="text-align: left;">NDJSON</td>
<td style="text-align: left;"><code>pl.read_ndjson()</code></td>
<td style="text-align: left;"><code>pl.scan_ndjson()</code></td>
<td style="text-align: left;"><code>df.write_ndjson()</code></td>
<td style="text-align: left;"><code>lf.sink_ndjson()</code></td>
</tr>
<tr>
<td style="text-align: left;">Delta</td>
<td style="text-align: left;"><code>pl.read_delta()</code></td>
<td style="text-align: left;"><code>pl.scan_delta()</code></td>
<td style="text-align: left;"><code>df.write_delta()</code></td>
<td style="text-align: left;">💡 coming soon?</td>
</tr>
</tbody>
</table>
<p>The reason you don’t see a <code>sink_delta()</code> method in Polars for Python is that it’s very new and not yet part of the stable public API.  It was introduced in late 2025 in Polars’ Rust core to allow streaming writes directly to Delta Lake without collecting all data in memory first.</p>
<p>As of the last stable release (early 2026), the Polars Python package does not expose LazyFrame.sink_delta() or DataFrame.sink_delta() in the public API.  The Polars team has indicated that sink_delta will likely appear in future stable releases once the Python bindings are finalized and tested.  Once available, this will enable Polars to do more with less in terms of RAM.</p>
<p>In this demo, we are going to use the lazy API to read the CSV files we downloaded above.  Once we've built up our transformations over the CSV sourced LazyFrame, we'll need to do a <code>.collect()</code> before using <code>write_delta()</code>.</p>
<pre><code class="language-python">import polars as pl

logging.info(f"Reading price paid data from location {source_path}...")

# Files area
price_paid_data = pl.scan_csv(
    source_path,  # ABFSS path to the CSV files in the Files area.
    has_header=False,
    null_values=[""],
    storage_options=storage_options,  # Provides Polars with the necessary credentials to read from Fabric.
    infer_schema=False,
    schema={
        "transaction_unique_identifier": pl.Utf8,
        "price": pl.Float64,
        "date_of_transfer": pl.Datetime,
        "postcode": pl.Utf8,
        "property_type": pl.Utf8,
        "old_new": pl.Utf8,
        "duration": pl.Utf8,
        "paon": pl.Utf8,
        "saon": pl.Utf8,
        "street": pl.Utf8,
        "locality": pl.Utf8,
        "town_city": pl.Utf8,
        "district": pl.Utf8,
        "county": pl.Utf8,
        "ppd_category_type": pl.Utf8,
        "record_status": pl.Utf8
    })
</code></pre>
<pre><code class="language-python">price_paid_data.head(5).collect_schema()
</code></pre>
<pre><code class="language-plaintext">Schema([('transaction_unique_identifier', String),
        ('price', Float64),
        ('date_of_transfer', Datetime(time_unit='us', time_zone=None)),
        ('postcode', String),
        ('property_type', String),
        ('old_new', String),
        ('duration', String),
        ('paon', String),
        ('saon', String),
        ('street', String),
        ('locality', String),
        ('town_city', String),
        ('district', String),
        ('county', String),
        ('ppd_category_type', String),
        ('record_status', String)])
</code></pre>
<h2 id="data-transformation">Data Transformation</h2>
<p>Now we can have a lazy frame in place, we can start to build up the transformations we want apply using Polars' composable expression API:</p>
<pre><code class="language-python"># Convert the property_type column from single letter codes to full descriptions
price_paid_data = (
    price_paid_data
    .with_columns(
        pl.when(pl.col("property_type") == "D")
        .then(pl.lit("Detached"))
        .when(pl.col("property_type") == "S")
        .then(pl.lit("Semi-Detached"))
        .when(pl.col("property_type") == "T")
        .then(pl.lit("Terraced"))
        .when(pl.col("property_type") == "F")
        .then(pl.lit("Flat/Maisonette"))
        .when(pl.col("property_type") == "O")
        .then(pl.lit("Other"))
        .otherwise(pl.col("property_type"))
        .alias("property_type")
    )
)
</code></pre>
<pre><code class="language-python"># Do the same of old_new
price_paid_data = (
    price_paid_data
    .with_columns(
        pl.when(pl.col("old_new") == "Y")
        .then(pl.lit("New"))
        .when(pl.col("old_new") == "N")
        .then(pl.lit("Old"))
        .otherwise(pl.col("old_new"))
        .alias("old_new")
    )
)
</code></pre>
<pre><code class="language-python"># Use regex to extract the postcode area (the first one or two letters)
price_paid_data = (
    price_paid_data
    .with_columns(
        pl.col("postcode")
        .str.extract(r"^([A-Z]{1,2})", 1)
        .alias("postcode_area")
    )
)
</code></pre>
<pre><code class="language-python"># Convert date_of_transfer from datetime to date
price_paid_data = (
    price_paid_data
    .with_columns(
        pl.col("date_of_transfer")
        .dt.date()
        .alias("date_of_transfer")
    )
)
</code></pre>
<h3 id="create-fact-table">Create fact table</h3>
<p>Select the core columns we want to use in the core fact table.</p>
<pre><code class="language-python"># Select relevant columns for downstream analysis
prices = price_paid_data.select([
    "price",
    "date_of_transfer",
    "postcode_area",
    "town_city",
    "property_type",
    "old_new",
])
</code></pre>
<h3 id="create-date-dimension">Create date dimension</h3>
<p>Use min and max dates to build date dimension table.</p>
<p>At this stage we need to materialise the data.  But given we are operating over a single column, the operation will be optimised through <strong>projection pushdown</strong>.</p>
<pre><code class="language-python">min_date = price_paid_data.select(pl.col("date_of_transfer").min()).collect()[0,0]
max_date = price_paid_data.select(pl.col("date_of_transfer").max()).collect()[0,0]
min_date, max_date
</code></pre>
<pre><code class="language-plaintext">(datetime.date(2023, 1, 1), datetime.date(2025, 11, 28))
</code></pre>
<pre><code class="language-python">dates = (
    pl.date_range(
        start=min_date,
        end=max_date,
        interval="1d",
        eager=True,
    ).
    to_frame(name="date")
    .with_columns([
        pl.col("date").dt.year().alias("year"),
        pl.col("date").dt.month().alias("month"),
        pl.col("date").dt.strftime("%B").alias("month_name"),
        pl.col("date").dt.day().alias("day"),
        pl.col("date").dt.weekday().alias("weekday"),
        pl.col("date").dt.strftime("%A").alias("weekday_name"),
        pl.col("date").dt.ordinal_day().alias("day_of_year"),
    ])
)   
</code></pre>
<h3 id="create-location-dimension">Create location dimension</h3>
<p>Assumption is there is a hierarchy in decreasing order of granularity:</p>
<ul>
<li>County</li>
<li>District</li>
<li>Town or City</li>
</ul>
<pre><code class="language-python">locations = (
    price_paid_data
    .select(
        [
            "county",
            "district",
            "town_city",
        ]
    )
    .unique()
)
</code></pre>
<h2 id="writing-to-delta-tables">Writing to Delta Tables</h2>
<p>It is common practice to write out a Polars DataFrame to a Delta table in the Tables area of your Lakehouse.</p>
<p>There are various write modes which are available:</p>
<p>Overwrite entire table:</p>
<pre><code class="language-python">df.write_delta(path, mode="overwrite")
</code></pre>
<p>Append to existing table:</p>
<pre><code class="language-python">df.write_delta(path, mode="append")
</code></pre>
<p>Merge (upsert) - returns a TableMerger for chaining:</p>
<pre><code class="language-python">(
    df.write_delta(
        path,
        mode="merge",
        delta_merge_options={
            "predicate": "source.id = target.id",
            "source_alias": "source",
            "target_alias": "target"
        }
    )
    .when_matched_update_all()
    .when_not_matched_insert_all()
    .execute()
)
</code></pre>
<h3 id="handling-timestamps">Handling Timestamps</h3>
<p>A common gotcha when writing Delta tables from Polars is timezone handling. Fabric's SQL endpoint expects timestamps with timezone information.</p>
<p>We can address this by adding timezone information, for example:</p>
<pre><code class="language-python">df = (
    df
    .with_columns(
        [
            pl.col("datetime_of_order")
            .dt.replace_time_zone("UTC")
            .alias("datetime_of_order")
        ]
    )
)
</code></pre>
<h3 id="write-tables">Write tables</h3>
<pre><code class="language-python">logger.info(f"Writing prices data to Delta table: {target_path_prices}")
prices.collect().write_delta(target_path_prices, mode="overwrite", storage_options=storage_options)
</code></pre>
<pre><code class="language-plaintext">INFO: Writing prices data to Delta table: ../data/fabric/Tables/house_price_analytics/prices
INFO:notebook_logger:Writing prices data to Delta table: ../data/fabric/Tables/house_price_analytics/prices
</code></pre>
<pre><code class="language-python">logger.info(f"Writing locations data to Delta table: {target_path_locations}")
locations.collect().write_delta(target_path_locations, mode="overwrite", storage_options=storage_options)
</code></pre>
<pre><code class="language-plaintext">INFO: Writing locations data to Delta table: ../data/fabric/Tables/house_price_analytics/locations
INFO:notebook_logger:Writing locations data to Delta table: ../data/fabric/Tables/house_price_analytics/locations
</code></pre>
<pre><code class="language-python">logger.info(f"Writing dates data to Delta table: {target_path_dates}")
dates.write_delta(target_path_dates, mode="overwrite", storage_options=storage_options)
</code></pre>
<pre><code class="language-plaintext">INFO: Writing dates data to Delta table: ../data/fabric/Tables/house_price_analytics/dates
INFO:notebook_logger:Writing dates data to Delta table: ../data/fabric/Tables/house_price_analytics/dates
</code></pre>
<h2 id="reading-from-deltalake">Reading from DeltaLake</h2>
<p>When we are reading delta files, we can use the Lazy execution framework to maximise scale and performance.</p>
<p>Let's illustrate this by doing generating some analytics in this notebook using the data we have just written to the lakehouse in Delta format.</p>
<pre><code class="language-python"># Load prices from Delta and filter them to exclude "Other" property types
logger.info(f"Reading prices data back from Delta table: {target_path_prices}")
prices = (
    pl.scan_delta(
        target_path_prices,
        storage_options=storage_options,
    )
    .filter(pl.col("property_type") != "Other")
)
</code></pre>
<pre><code class="language-plaintext">INFO: Reading prices data back from Delta table: ../data/fabric/Tables/house_price_analytics/prices
INFO:notebook_logger:Reading prices data back from Delta table: ../data/fabric/Tables/house_price_analytics/prices
</code></pre>
<pre><code class="language-python"># Load the date dimension, a new month_tag column in the form YYYY_MM
logger.info(f"Reading dates data back from Delta table: {target_path_dates}")
dates = (
    pl.scan_delta(
        target_path_dates,
        storage_options=storage_options,
    )
    .with_columns(
        [
            pl.col("date").dt.strftime("%Y_%m").alias("month_tag")
        ]
    )
)
</code></pre>
<pre><code class="language-plaintext">INFO: Reading dates data back from Delta table: ../data/fabric/Tables/house_price_analytics/dates
INFO:notebook_logger:Reading dates data back from Delta table: ../data/fabric/Tables/house_price_analytics/dates
</code></pre>
<pre><code class="language-python"># Now join the two tables to get month_tag into the prices table
prices = (
    prices
    .join(
        dates.select(
            [
                "date",
                "month_tag"
            ]
        ),
        left_on="date_of_transfer",
        right_on="date",
        how="left"
    )
)
</code></pre>
<pre><code class="language-python"># Finally summarise the data up to monthly level by property type
monthly_summary = (
    prices
    .group_by(
        [
            "month_tag",
            "property_type"
        ]
    )
    .agg(
        [
            pl.len().alias("number_of_transactions"),
            pl.col("price").median().alias("median_price"),
            pl.col("price").min().alias("min_price"),
            pl.col("price").max().alias("max_price"),
        ]
    )
    .sort(
        [
            "month_tag",
            "property_type"
        ]
    )
)
</code></pre>
<pre><code class="language-python">monthly_summary = monthly_summary.collect()
</code></pre>
<pre><code class="language-python"># Plot the monthly summary using Plotly
import plotly.express as px

fig = px.line(
    monthly_summary,
    x="month_tag",
    y="median_price",
    color="property_type",
    title="Median House Prices by Property Type",
    labels={
        "month_tag": "Month",
        "median_price": "Median Price (£)",
        "property_type": "Property Type"
    }
)
fig.update_yaxes(range=[0, 600000])
fig.show()
</code></pre>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/01/chart_5.png"><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/01/chart_5.png" alt="Time series chart showing changes in house prices over time" title="Time series chart showing changes in house prices over time" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/01/chart_5.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/01/chart_5.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/01/chart_5.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/01/chart_5.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></a></p>
<h2 id="performance-optimisation-tips">Performance Optimisation Tips</h2>
<ol>
<li>Use lazy evaluation for large datasets - for datasets approaching memory limits, lazy evaluation lets Polars optimise the query plan.</li>
<li>Optimise row groups for DirectLake - if your Delta tables will be consumed by Power BI's DirectLake mode, configure larger rowgroups.  See this blog <a href="https://fabric.guru/delta-lake-tables-for-optimal-direct-lake-performance-in-fabric-python-notebook">"Delta Lake Tables For Optimal Direct Lake Performance In Fabric Python Notebook"</a> from Sandeep Pawar (Principal Program Manager, Microsoft Fabric CAT) for more details.</li>
<li>Scale up your notebook environment when needed - using the <a href="https://learn.microsoft.com/en-us/fabric/data-engineering/using-python-experience-on-notebook#session-configuration-magic-command"><code>%%configure</code></a> magic command in a cell at the top of the notebook.  Available configurations: 4, 8, 16, 32, or 64 vCores (memory scales proportionally).</li>
</ol>
<h2 id="current-limitations">Current Limitations</h2>
<p>A few things to be aware of:</p>
<ul>
<li><strong>V-ORDER</strong> - Fabric's V-ORDER optimisation requires Spark; Polars-written Delta tables won't have this applied. Tuning rowgroups can partially compensate.</li>
<li><strong>Liquid Clustering</strong> - similarly, Liquid Clustering is Spark-only.</li>
<li><strong>Polars version</strong> - the pre-installed version may lag behind the latest release. You can upgrade with <code>%pip install polars --upgrade</code>, though this adds notebook startup time.</li>
<li><strong>Memory ceiling</strong> - the maximum single-node configuration is 64 vCores. Beyond that, you'll need Spark or Polars Cloud (when available).</li>
</ul>
<h2 id="further-reading">Further reading</h2>
<ul>
<li><a href="https://learn.microsoft.com/en-us/fabric/data-engineering/using-python-experience-on-notebook">Microsoft Learn: Python experience on Notebook</a></li>
<li><a href="https://learn.microsoft.com/en-us/fabric/data-engineering/fabric-notebook-selection-guide">Microsoft Learn: Choosing Between Python and PySpark Notebooks</a></li>
<li><a href="https://delta-io.github.io/delta-rs/integrations/delta-lake-polars/">DeltaLake Documentation: Using Delta Lake with polars</a></li>
<li><a href="https://fabric.guru/working-with-delta-tables-in-fabric-python-notebook-using-polars">Sandeep Pawar: Working With Delta Tables in Fabric Python Notebook Using Polars</a></li>
</ul>
<h2 id="summary">Summary</h2>
<p>Polars on Microsoft Fabric offers a compelling alternative to Spark for many data engineering workloads. The combination of Polars' performance, Fabric's native OneLake integration, and the cost efficiency of single-node compute creates a practical path for teams who want enterprise-grade data pipelines without the complexity of distributed systems.</p>
<p>Start small, measure your workloads, and scale to Spark only when you genuinely need distributed compute. For many teams, that day may never come.</p>
<p>This is Part 4 of our Adventures in Polars series:</p>
<ul>
<li><strong>Part 1: <a href="https://endjin.com/blog/polars-faster-pipelines-simpler-infrastructure-happier-engineers">Why Polars Matters</a></strong> — The Decision Makers Guide for Polars.</li>
<li><strong>Part 2: <a href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast">What Makes Polars So Scalable and Fast?</a></strong> — The technical deep-dive: lazy evaluation, query optimisation, parallelism, and the Rust foundation.</li>
<li><strong>Part 3: <a href="https://endjin.com/blog/practical-polars-code-examples-everyday-data-tasks">Code Examples for Everyday Data Tasks</a></strong> — Hands-on examples showing Polars in action.</li>
</ul>
<hr>
<p><em>Are you running Polars workloads on Microsoft Fabric? Have you found effective patterns for switching between local development and cloud deployment? We'd love to hear about your experiences in the comments below!</em></p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Adventures in Polars</h3>
        <span class="series-toc__count">4 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/polars-faster-pipelines-simpler-infrastructure-happier-engineers" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Why Polars Matters</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">What Makes Polars So Scalable and Fast?</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/practical-polars-code-examples-everyday-data-tasks" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Code Examples</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">4.</span>
                <span class="series-toc__part-title">Polars Workloads on Fabric</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Practical Polars: Code Examples for Everyday Data Tasks</title>
      <description>Unlock Python Polars with this hands-on guide featuring practical code examples for data loading, cleaning, transformation, aggregation, and advanced operations that you can apply to your own data analysis projects.</description>
      <link>https://endjin.com/blog/practical-polars-code-examples-everyday-data-tasks</link>
      <guid isPermaLink="true">https://endjin.com/blog/practical-polars-code-examples-everyday-data-tasks</guid>
      <pubDate>Thu, 29 Jan 2026 05:33:00 GMT</pubDate>
      <category>Polars</category>
      <category>DataFrame</category>
      <category>Python</category>
      <category>Data Analysis</category>
      <category>Code Examples</category>
      <category>Data Transformation</category>
      <category>Data Science</category>
      <category>Microsoft Fabric</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/01/polars-code-examples.png" />
      <dc:creator>Barry Smart</dc:creator>
      <content:encoded><![CDATA[<p>TL;DR: This hands-on guide provides practical Polars code examples for common data tasks. We cover data loading from various sources (CSV, Parquet, JSON), data exploration techniques, powerful data transformations using expressions, aggregations and grouping and joining datasets. By explicitly showing the code for these everyday tasks, this guide serves as a reference for applying Polars' performance advantages to real-world data analysis workflows. Both eager execution (for interactive work) and lazy execution (for optimized performance) approaches are demonstrated, helping you leverage Polars' full potential regardless of your use case.</p>
<p>In our previous articles, we've <a href="https://endjin.com/blog/polars-faster-pipelines-simpler-infrastructure-happier-engineers">introduced Polars</a> and <a href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast">explored its performance architecture</a>. Now it's time to get hands-on with practical examples of Polars in action.</p>
<p>This guide focuses on concrete code examples for common data tasks. Whether you're new to Polars or looking to expand your skills, these examples will help you apply Polars to your everyday data analysis workflows.</p>
<p>We use a set of data downloaded from the <a href="https://data.worldbank.org/">World Bank Open Data</a> specifically via the <a href="https://data360.worldbank.org/en/api">Data 360 API</a>. This data is made available by the World Bank under a <a href="https://creativecommons.org/licenses/by/4.0/">Creative Commons 4.0</a> license. It's a brilliant example of the power of open data and we'd like to thank the World Bank for making this data available.</p>
<p>Specifically, we will be working with the <strong>World Bank Indicators</strong> dataset, which contains macroeconomic series (GDP, population, education, etc.) for every country and year. We have prepared this data in a range of different formats to demonstrate how Polars can successfully interact with different source data formats.</p>
<p>You can download and run the source code in this blog which is available (along with other Polars examples) in the following public GitHub repo: <a href="https://github.com/endjin/endjin-polars-examples">https://github.com/endjin/endjin-polars-examples</a></p>
<h2 id="before-we-start-what-is-a-dataframe">Before we start: what is a DataFrame?</h2>
<p>Polars is a <strong>DataFrame</strong> library available in Python. But what do we mean by a DataFrame?</p>
<p>A dataframe is a two-dimensional, in memory, tabular data structure that organises information into rows and columns, much like a spreadsheet or a database table. Each column represents a variable or attribute, while each row represents a single observation or record.</p>
<p>In Polars specifically, a DataFrame is a 2-dimensional heterogeneous data structure that is composed of multiple <strong>Series</strong>. A Series is a 1-dimensional homogeneous data structure, meaning it holds data of a single data type (e.g., all integers, or all strings). These Series represent the columns in the dataframe.</p>
<p>What makes dataframes particularly powerful is that while each individual column (Series) is homogeneous and strictly typed, the DataFrame as a whole allows for different data types across its column (e.g. strings, integers, floats, dates, boolean, complex types) while still allowing operations across the entire structure. This flexibility makes them an intuitive and practical abstraction for working with the kind of structured data that dominates analytical workloads.</p>
<p>For Python developers, the dataframe concept was popularised by <a href="https://pandas.pydata.org/">pandas</a>, which became the de facto standard for data manipulation over the past decade. However, as datasets have grown larger and performance expectations have increased, the limitations of pandas have become more apparent.</p>
<p>This has created space for newer libraries like <a href="https://pola.rs/">Polars</a> to reimagine the dataframe from the ground up, retaining the familiar mental model while delivering significantly improved performance through modern design choices such as lazy evaluation, parallel execution, and memory-efficient columnar storage that take advantage of modern compute hardware.</p>
<p>If you've worked with pandas, SQL result sets, or even Excel tables, you already understand the core concept. Polars simply executes on it faster and more efficiently.</p>
<h2 id="developer-experience-come-for-the-speed-stay-for-the-api">Developer Experience: "Come for the Speed, Stay for the API"</h2>
<p>The Polars community has a saying: "Come for the speed, stay for the API." This captures an important truth about Polars' adoption - while performance often drives initial interest, the well-designed developer experience keeps users engaged.</p>
<p>Polars achieves this through:</p>
<ul>
<li><strong>Consistency</strong>- methods use snake_case naming with predictable patterns</li>
<li><strong>Fluent interface</strong> - method chaining creates readable data transformation pipelines</li>
<li><strong>Error messages</strong> - clear, actionable feedback when something goes wrong</li>
<li><strong>Schema enforcement</strong> - strong typing that prevents common Pandas "surprises" that often thwart developers</li>
<li><strong>Expression system</strong> - a composable language for data manipulation</li>
</ul>
<p>As Vink puts it: "just write what you want and we will apply those optimizations for you... write readable idiomatic queries which just explain your intent and we will figure out how to make it fast."<a id="fnref:1" href="https://endjin.com/blog/practical-polars-code-examples-everyday-data-tasks#fn:1" class="footnote-ref"><sup>1</sup></a> This philosophy places user experience on equal footing with performance.</p>
<p>This is the behaviour and benefit of a well architected system and precisely why we do the <a href="https://endjin.com/blog/how-dotnet-10-boosted-ais-dotnet-performance-by-7-percent-for-free">yearly .NET Performance Boost posts</a>: we change 0 lines of code and yet we still get big performance benefits from people in the core team optimising the internals.</p>
<p>For those who are familiar with the <a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/index.html">PySpark SQL and DataFrame API</a>, adoption of Polars will be quite straightforward.</p>
<p>For those who are coming from a Pandas background: Polars has some similarities in terms of method names, but the fundamental structure of the API is different and perhaps more fundamentally Polars applies a stricter set of principles. We may cover a "Polars versus Pandas" blog in the future, if there is demand for more detail.</p>
<h2 id="setup-and-installation">Setup and Installation</h2>
<p>Before we begin, let's make sure Polars is installed and imported correctly.</p>
<p>We are using <a href="https://docs.astral.sh/uv/">uv</a> to manage our Python environment in this demo, so we have run the following command:</p>
<pre><code class="language-plaintext">uv add polars
</code></pre>
<p>If you are using <a href="https://pip.pypa.io/en/stable/index.html">pip</a>, the equivalent command would be <code>pip install polars</code>.</p>
<p>If you are using <a href="https://python-poetry.org/">poetry</a>, the equivalent command would be <code>poetry add polars</code>.</p>
<p>Next stage is to import Polars (the convention is to use an alias of <code>pl</code>) and check the version we are using:</p>
<pre><code class="language-python"># Import Polars
import polars as pl

# Check version
f"Polars version: {pl.__version__}"
</code></pre>
<pre><code class="language-plaintext">'Polars version: 1.35.2'
</code></pre>
<h2 id="creating-dataframes-from-scratch">Creating Dataframes from scratch</h2>
<p>There are a range of other formats which are supported for creation of Polars dataframes in code, the most common one we tend to adopt is from a <code>list</code> of <code>dict</code> (or <code>dataclass</code>) objects as demonstrated below. This example also shows how Polars elegantly handles null values.</p>
<p>The creation of a dataframe in code is useful for creating test cases in unit tests and for exploring the Polars API with small "toy" datasets.</p>
<pre><code class="language-python">from datetime import datetime

# From list of dictionary based records
df = pl.DataFrame(
    [
        {'column_a': 1, 'column_b': 'Red', 'column_c': None, 'column_d': 10.5, 'column_e': datetime(2020, 1, 1)},
        {'column_a': 2, 'column_b': 'Blue', 'column_c': False, 'column_d': None, 'column_e': datetime(2021, 2, 2)},
        {'column_a': None, 'column_b': 'Green', 'column_c': True, 'column_d': 30.1, 'column_e': None},
        {'column_a': 4, 'column_b': None, 'column_c': False, 'column_d': 40.7, 'column_e': datetime(2023, 4, 4)},
        {'column_a': 5, 'column_b': 'Purple', 'column_c': True, 'column_d': 50.2, 'column_e': datetime(2024, 5, 5)},
    ]
)
df
</code></pre>
<table>
<thead>
<tr>
<th>column_a</th>
<th>column_b</th>
<th>column_c</th>
<th>column_d</th>
<th>column_e</th>
</tr>
</thead>
<tbody>
<tr>
<td>i64</td>
<td>str</td>
<td>bool</td>
<td>f64</td>
<td>datetime[μs]</td>
</tr>
<tr>
<td>1</td>
<td>"Red"</td>
<td>null</td>
<td>10.5</td>
<td>2020-01-01 00:00:00</td>
</tr>
<tr>
<td>2</td>
<td>"Blue"</td>
<td>false</td>
<td>null</td>
<td>2021-02-02 00:00:00</td>
</tr>
<tr>
<td>null</td>
<td>"Green"</td>
<td>true</td>
<td>30.1</td>
<td>null</td>
</tr>
<tr>
<td>4</td>
<td>null</td>
<td>false</td>
<td>40.7</td>
<td>2023-04-04 00:00:00</td>
</tr>
<tr>
<td>5</td>
<td>"Purple"</td>
<td>true</td>
<td>50.2</td>
<td>2024-05-05 00:00:00</td>
</tr>
</tbody>
</table>
<p>In the output above, you can see Polars displays the dataframe in tabular form with column names and data types displayed.</p>
<p>Polars implements a strict, statically-known type system. Unlike pandas, where output data types can change depending on the data itself, Polars guarantees that schemas are known before query execution. This means that when you apply a join, filter, or transformation, you can predict exactly what the output type will be independently from the actual data flowing through.</p>
<p>This predictability is a significant benefit for developers: if you expect an integer column and write downstream code that depends on integer behaviour, you won't discover a surprise float conversion halfway through a pipeline (as can occur with null values in an integer column).</p>
<p>If there's a type mismatch, Polars throws an error before the query runs, not twenty steps into your processing chain. As Ritchie Vink, the creator of Polars, puts it: "this strictness will save you a lot of headaches".</p>
<p>In the top left area, the "shape" of the dataframe is displayed using the tuple (number of rows, number of columns).</p>
<h2 id="loading-data-from-other-sources">Loading Data from other sources</h2>
<p>Of course, in the majority of scenarios you will want to load data into a Polars dataframe from an external datasource.</p>
<p>Unlike similar tools such as DuckDB, Polars does not offer a native means with which to persist data on storage. It relies on well established standards to both ingest and persist data.</p>
<p>We now walk through the key examples below.</p>
<h3 id="loading-csv-data">Loading CSV data</h3>
<p>One of the most common sources of data will be from Comma Separated Value (CSV) files. Here we load the <code>countries.csv</code> data (country metadata) from the World Bank.</p>
<p>Polars provides two approaches for loading CSV files. In this case, the file is small (less than 300 rows of data across 9 columns), so we load it using the "eager" <a href="https://docs.pola.rs/api/python/stable/reference/api/polars.read_csv.html"><code>polars.read_csv</code></a> method. Later on this in this blog we'll demonstrate how the "lazy loading" <a href="https://docs.pola.rs/api/python/stable/reference/api/polars.scan_csv.html"><code>polars.scan_csv</code></a> method may be a better choice for larger data sets.</p>
<p>The eager approach immediately reads the entire file into memory and returns a DataFrame, which is convenient for exploratory work with smaller files such as in this use case.</p>
<p>You can see that it uses sensible defaults to read the header row as column names, it also infers data types from the data (we need to provide an additional hint to treat empty strings as <code>null</code> values to enable <code>longitude</code> and <code>latitude</code> to be treated as floating point numbers).</p>
<pre><code class="language-python">countries = pl.read_csv(CSV_COUNTRIES_PATH, infer_schema=True, null_values=[""])
</code></pre>
<pre><code class="language-python"># Display the dataframe.  By default, Polars shows the first and last 5 rows.
countries
</code></pre>
<table>
<thead>
<tr>
<th>country_code</th>
<th>iso2_code</th>
<th>country_name</th>
<th>region</th>
<th>region_id</th>
<th>income_level</th>
<th>capital_city</th>
<th>longitude</th>
<th>latitude</th>
</tr>
</thead>
<tbody>
<tr>
<td>str</td>
<td>str</td>
<td>str</td>
<td>str</td>
<td>str</td>
<td>str</td>
<td>str</td>
<td>f64</td>
<td>f64</td>
</tr>
<tr>
<td>ABW</td>
<td>AW</td>
<td>Aruba</td>
<td>Latin America &amp; Caribbean</td>
<td>LCN</td>
<td>High income</td>
<td>Oranjestad</td>
<td>-70.0167</td>
<td>12.5167</td>
</tr>
<tr>
<td>AFE</td>
<td>ZH</td>
<td>Africa Eastern and Southern</td>
<td>Aggregates</td>
<td>NA</td>
<td>Aggregates</td>
<td>null</td>
<td>null</td>
<td>null</td>
</tr>
<tr>
<td>AFG</td>
<td>AF</td>
<td>Afghanistan</td>
<td>Middle East, North Africa, Afg…</td>
<td>MEA</td>
<td>Low income</td>
<td>Kabul</td>
<td>69.1761</td>
<td>34.5228</td>
</tr>
<tr>
<td>AFR</td>
<td>A9</td>
<td>Africa</td>
<td>Aggregates</td>
<td>NA</td>
<td>Aggregates</td>
<td>null</td>
<td>null</td>
<td>null</td>
</tr>
<tr>
<td>AFW</td>
<td>ZI</td>
<td>Africa Western and Central</td>
<td>Aggregates</td>
<td>NA</td>
<td>Aggregates</td>
<td>null</td>
<td>null</td>
<td>null</td>
</tr>
<tr>
<td>…</td>
<td>…</td>
<td>…</td>
<td>…</td>
<td>…</td>
<td>…</td>
<td>…</td>
<td>…</td>
<td>…</td>
</tr>
<tr>
<td>XZN</td>
<td>A5</td>
<td>Sub-Saharan Africa excluding S…</td>
<td>Aggregates</td>
<td>NA</td>
<td>Aggregates</td>
<td>null</td>
<td>null</td>
<td>null</td>
</tr>
<tr>
<td>YEM</td>
<td>YE</td>
<td>Yemen, Rep.</td>
<td>Middle East, North Africa, Afg…</td>
<td>MEA</td>
<td>Low income</td>
<td>Sana'a</td>
<td>44.2075</td>
<td>15.352</td>
</tr>
<tr>
<td>ZAF</td>
<td>ZA</td>
<td>South Africa</td>
<td>Sub-Saharan Africa</td>
<td>SSF</td>
<td>Upper middle income</td>
<td>Pretoria</td>
<td>28.1871</td>
<td>-25.746</td>
</tr>
<tr>
<td>ZMB</td>
<td>ZM</td>
<td>Zambia</td>
<td>Sub-Saharan Africa</td>
<td>SSF</td>
<td>Lower middle income</td>
<td>Lusaka</td>
<td>28.2937</td>
<td>-15.3982</td>
</tr>
<tr>
<td>ZWE</td>
<td>ZW</td>
<td>Zimbabwe</td>
<td>Sub-Saharan Africa</td>
<td>SSF</td>
<td>Lower middle income</td>
<td>Harare</td>
<td>31.0672</td>
<td>-17.8312</td>
</tr>
</tbody>
</table>
<h3 id="loading-from-parquet-files">Loading from Parquet files</h3>
<p>Parquet has become the interchange format of choice for high-performance analytics, and Polars is designed to take full advantage of its characteristics. Unlike CSV, Parquet is a columnar, compressed, binary format that embeds schema metadata directly in the file - column names, data types, and statistics are all self-describing. This means no inference guesswork is required when loading data, and the strict typing aligns perfectly with Polars' philosophy of statically-known schemas.</p>
<p>We read parquet files using the <a href="https://docs.pola.rs/api/python/stable/reference/api/polars.read_parquet.html"><code>polars.read_parquet</code></a> method (or <a href="https://docs.pola.rs/api/python/stable/reference/api/polars.scan_parquet.html"><code>polars.scan_parquet</code></a> for lazy loading).</p>
<p>The columnar layout delivers significant benefits for analytical workloads. Compression is highly effective because similar data types are stored together, resulting in files that are often 10-100x smaller than equivalent CSVs. More importantly, Polars can read only the columns your query actually needs without touching the rest of the file: a technique known as projection pushdown that dramatically reduces I/O.</p>
<p>So is a recommendation that if you are going to working with a large dataset stored in CSV format, it is often worthwhile to convert from CSV to Parquet because it will reduce the footprint of the data on the filesystem and improve the speed of the inner dev loop of exploring the data.</p>
<h4 id="intelligent-scan-optimisation">Intelligent Scan Optimisation</h4>
<p>Where Polars really shines is in its use of Parquet's embedded statistics. Each Parquet file contains metadata about its row groups, including minimum and maximum values for each column. When you apply a filter in a lazy query, Polars examines these statistics and can skip entire row groups that cannot possibly contain matching rows - without reading any of the underlying data. Combined with predicate pushdown (applying filters at scan level rather than after materialisation), this means Polars often reads only a fraction of the file.</p>
<p>As Ritchie Vink explains: "if you do a filter, we look at the parquet statistics in the file and we will not first read the whole file and then apply the filter, we will apply the filters while we're reading it in." The result is that a well-optimised Parquet based workflow can be orders of magnitude faster than the equivalent CSV processing, with substantially lower memory consumption.</p>
<h4 id="loading-multiple-files-in-one-operation">Loading multiple files in one operation</h4>
<p>In practice, data lakes rarely consist of a single Parquet file. Upstream processes typically write data in partitions. Perhaps one file per day, per source system, or per logical partition. Rather than requiring you to enumerate each file individually, Polars supports glob patterns that match multiple files in a single operation.</p>
<p>We have simulated Hive-style partitioned data in the test data by creating a suite of parquet files using a partitioning strategy based on <code>year</code>. This results in one folder for each year and one (or potentially more) Parquet file within each folder.</p>
<p>Using the globbing pattern we can load all of the files in one operation using the following pattern where the * wildcard matches any characters within a single directory level (you can use ** to match across multiple directory levels).</p>
<pre><code class="language-python">metrics = pl.read_parquet(PARQUET_FOLDER / "year=*" / "*.parquet")
</code></pre>
<pre><code class="language-python"># Display 5 randomly sampled rows from the dataframe
metrics.select(["country_code", "country_name", "region", "year", "WB_WDI_SP_POP_TOTL"]).sample(5)
</code></pre>
<table>
<thead>
<tr>
<th>country_code</th>
<th>country_name</th>
<th>region</th>
<th>year</th>
<th>WB_WDI_SP_POP_TOTL</th>
</tr>
</thead>
<tbody>
<tr>
<td>str</td>
<td>str</td>
<td>str</td>
<td>i64</td>
<td>f64</td>
</tr>
<tr>
<td>CRI</td>
<td>Costa Rica</td>
<td>Latin America &amp; Caribbean</td>
<td>1974</td>
<td>2.039643e6</td>
</tr>
<tr>
<td>NGA</td>
<td>Nigeria</td>
<td>Sub-Saharan Africa</td>
<td>1985</td>
<td>8.4897973e7</td>
</tr>
<tr>
<td>IDN</td>
<td>Indonesia</td>
<td>East Asia &amp; Pacific</td>
<td>2019</td>
<td>2.72489381e8</td>
</tr>
<tr>
<td>CHN</td>
<td>China</td>
<td>East Asia &amp; Pacific</td>
<td>2018</td>
<td>1.4028e9</td>
</tr>
<tr>
<td>ALB</td>
<td>Albania</td>
<td>Europe &amp; Central Asia</td>
<td>1975</td>
<td>2.404831e6</td>
</tr>
</tbody>
</table>
<h3 id="loading-multiple-json-files">Loading multiple JSON files</h3>
<p>Polars supports two primary JSON formats: newline-delimited JSON (NDJSON) and standard JSON arrays. NDJSON, where each line is a separate JSON object, is the more performant option for large datasets because it can be processed in a streaming fashion without loading the entire file into memory.</p>
<p>As with CSV, Polars infers the schema by sampling the data. The same principle applies: once the schema is established, it's enforced strictly throughout the query. For nested JSON structures, Polars maps these to its native Struct and List types, which are properly typed and benefit from Polars' vectorised execution.</p>
<p>That said, JSON is inherently less efficient than columnar formats for analytical workloads. It's row-oriented, text-based, and lacks the embedded statistics that enable predicate pushdown. If you're regularly processing JSON at scale, it's often worth converting to Parquet as a one-time transformation - you'll recoup the conversion cost quickly through faster subsequent reads.</p>
<p>In this example we are using the globbing functionality to load multiple JSON files simulating the type of raw data that is often generated from web APIs.</p>
<pre><code class="language-python">json_metrics = (
    pl.read_ndjson(JSON_FOLDER / "*_data.json")
)
</code></pre>
<p>When we display the first 2 rows of the JSON data below, you can see that it has loaded two of the columns as custom datatypes. Polars has scanned the first N elements of JSON and determined that the <code>country_info</code> column contains a <code>struct</code> datatype with 6 elements. The <code>indicators</code> column contains a <code>list[struct]</code> an array of structs each with 4 elements.</p>
<p>This works really well if your JSON data is consistent in terms of structure and content. Leaving Polars to define the schema in this way for JSON data which can vary in structure beyond the first N rows can be problematic and will likely need manual definition of schema to load reliably.</p>
<pre><code class="language-python"># Display the first 2 rows of the dataframe.
json_metrics.head(2)
</code></pre>
<table>
<thead>
<tr>
<th>country_code</th>
<th>country_info</th>
<th>indicators</th>
</tr>
</thead>
<tbody>
<tr>
<td>str</td>
<td>struct[6]</td>
<td>list[struct[4]]</td>
</tr>
<tr>
<td>ALB</td>
<td>{"Albania","Europe &amp; Central Asia","Upper middle income","Tirane","41.3317","19.8172"}</td>
<td>[{"WB_WDI_NY_GDP_PCAP_CD","GDP per capita (current US$)","Weighted average",[{2019,6069.439031}, {2004,2446.909499}, … {1986,693.873475}]}, {"WB_WDI_GC_DOD_TOTL_GD_ZS","Central government debt, total (% of GDP)","Weighted average",[{2019,74.808252}, {2017,74.523341}, … {1995,29.450991}]}, … {"WB_WDI_SP_DYN_LE00_IN","Life expectancy at birth, total (years)","Weighted average",[{2019,79.467}, {2004,75.951}, … {1973,67.107}]}]</td>
</tr>
<tr>
<td>ARG</td>
<td>{"Argentina","Latin America &amp; Caribbean","Upper middle income","Buenos Aires","-34.6118","-58.4173"}</td>
<td>[{"WB_WDI_SP_DYN_LE00_IN","Life expectancy at birth, total (years)","Weighted average",[{2019,76.847}, {2004,74.871}, … {1970,65.647}]}, {"WB_WDI_SP_POP_TOTL","Population, total","Sum",[{2004,3.8815916e7}, {2013,4.2582455e7}, … {1970,2.3878327e7}]}, … {"WB_WDI_NY_GDP_MKTP_CD","GDP (current US$)","Gap-filled total",[{2019,4.4775e11}, {2004,1.6466e11}, … {1970,3.1584e10}]}]</td>
</tr>
</tbody>
</table>
<h3 id="loading-from-duckdb">Loading from DuckDB</h3>
<p><a href="https://endjin.com/blog/duckdb-rise-of-in-process-analytics-understanding-data-singularity">DuckDB</a> and Polars are a powerful combination for data analysis. DuckDB is an in-process SQL OLAP database management system, which means it runs inside the same process as the application. This makes it incredibly fast for analytical queries.</p>
<p>The <code>.pl()</code> method provides a seamless way to convert the result of a DuckDB query into a Polars DataFrame, allowing you to move data efficiently between the two tools.</p>
<p>In the example below, we are doing a simple <code>SELECT * FROM</code> query to select all of the data from a single table. In the more detailed examples below, we show how to combine the power of DuckDB and Polars for more advanced analytics.</p>
<pre><code class="language-python">import duckdb

with duckdb.connect(f'{DUCKDB_PATH}', read_only=True) as duckdb_connection:
    duck_data = duckdb_connection.sql('SELECT * FROM data').pl()

duck_data.glimpse()
</code></pre>
<pre><code class="language-plaintext">Rows: 9715
Columns: 4
$ country_code   &lt;str&gt; 'VNM', 'VNM', 'GBR', 'CAN', 'CHL', 'CHN', 'VNM', 'GBR', 'USA', 'VNM'
$ year           &lt;i64&gt; 2011, 2018, 2016, 2010, 2010, 2010, 1999, 2009, 2009, 2009
$ indicator_code &lt;str&gt; 'WB_WDI_EG_USE_PCAP_KG_OE', 'WB_WDI_EG_USE_PCAP_KG_OE', 'WB_WDI_EG_USE_PCAP_KG_OE', 'WB_WDI_EG_USE_PCAP_KG_OE', 'WB_WDI_EG_USE_PCAP_KG_OE', 'WB_WDI_EG_USE_PCAP_KG_OE', 'WB_WDI_EG_USE_PCAP_KG_OE', 'WB_WDI_EG_USE_PCAP_KG_OE', 'WB_WDI_EG_USE_PCAP_KG_OE', 'WB_WDI_EG_USE_PCAP_KG_OE'
$ value          &lt;f64&gt; 663.57444, 885.646842, 2696.97999, 7660.754521, 1795.68173, 1899.799961, 358.075426, 3144.002013, 7053.358467, 615.142147
</code></pre>
<h2 id="data-exploration">Data Exploration</h2>
<p>Once your data is loaded, Polars provides several methods to explore and understand it.</p>
<p>We have already shown a few examples above:</p>
<ul>
<li><a href="https://docs.pola.rs/api/python/stable/reference/dataframe/api/polars.DataFrame.head.html"><code>polars.DataFrame.head()</code></a> - display the first N rows of the dataframe.</li>
<li><a href="https://docs.pola.rs/api/python/stable/reference/dataframe/api/polars.DataFrame.tail.html"><code>polars.DataFrame.tail()</code></a> - display the last N rows of the dataframe.</li>
<li><a href="https://docs.pola.rs/api/python/stable/reference/dataframe/api/polars.DataFrame.glimpse.html"><code>polars.DataFrame.glimpse()</code></a> - shows the values of the first few rows of a dataframe, but formats the output differently from head and tail. Here, each line of the output corresponds to a single column, making it easier to inspect wider dataframes.</li>
<li><a href="https://docs.pola.rs/api/python/stable/reference/dataframe/api/polars.DataFrame.sample.html"><code>polars.DataFrame.sample()</code></a> - samples n random rows from a DataFrame.</li>
<li><a href="https://docs.pola.rs/api/python/stable/reference/dataframe/api/polars.DataFrame.columns.html"><code>polars.DataFrame.columns</code></a> - provides a list of the columns in the dataframe.</li>
<li><a href="https://docs.pola.rs/api/python/stable/reference/dataframe/api/polars.DataFrame.describe.html"><code>polars.DataFrame.describe()</code></a> - generates summary statistics for the dataframe.</li>
</ul>
<pre><code class="language-python"># Display the summary statistics for the dataframe.
countries.describe()
</code></pre>
<table>
<thead>
<tr>
<th>statistic</th>
<th>country_code</th>
<th>iso2_code</th>
<th>country_name</th>
<th>region</th>
<th>region_id</th>
<th>income_level</th>
<th>capital_city</th>
<th>longitude</th>
<th>latitude</th>
</tr>
</thead>
<tbody>
<tr>
<td>str</td>
<td>str</td>
<td>str</td>
<td>str</td>
<td>str</td>
<td>str</td>
<td>str</td>
<td>str</td>
<td>f64</td>
<td>f64</td>
</tr>
<tr>
<td>count</td>
<td>296</td>
<td>296</td>
<td>296</td>
<td>296</td>
<td>296</td>
<td>296</td>
<td>211</td>
<td>211.0</td>
<td>211.0</td>
</tr>
<tr>
<td>null_count</td>
<td>0</td>
<td>0</td>
<td>0</td>
<td>0</td>
<td>0</td>
<td>0</td>
<td>85</td>
<td>85.0</td>
<td>85.0</td>
</tr>
<tr>
<td>mean</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>19.139549</td>
<td>18.889009</td>
</tr>
<tr>
<td>std</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>70.391069</td>
<td>24.210877</td>
</tr>
<tr>
<td>min</td>
<td>ABW</td>
<td>1A</td>
<td>Afghanistan</td>
<td>Aggregates</td>
<td>EAS</td>
<td>Aggregates</td>
<td>Abu Dhabi</td>
<td>-175.216</td>
<td>-41.2865</td>
</tr>
<tr>
<td>25%</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>-13.7</td>
<td>4.60987</td>
</tr>
<tr>
<td>50%</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>19.2595</td>
<td>17.3</td>
</tr>
<tr>
<td>75%</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>null</td>
<td>50.5354</td>
<td>40.0495</td>
</tr>
<tr>
<td>max</td>
<td>ZWE</td>
<td>ZW</td>
<td>Zimbabwe</td>
<td>Sub-Saharan Africa</td>
<td>SSF</td>
<td>Upper middle income</td>
<td>Zagreb</td>
<td>179.089567</td>
<td>64.1836</td>
</tr>
</tbody>
</table>
<h2 id="polars-expressions">Polars Expressions</h2>
<p>So far, we've covered the basics but where Polars starts to differentiate is through its composable, expressive API that adopts a functional programming approach - a good fit with data processing use cases.</p>
<p>The Polars team have worked hard to make the domain specific language (DSL) consistent and therefore intuitive to use.</p>
<p>The foundation of the language is the concept of "expressions": composable building blocks that each specialise in a specific data wrangling task such as:</p>
<ul>
<li>Filtering - filtering to a specific subset of data based on the values in specific columns.</li>
<li>Aggregating, summarising - often applied when grouping up data based on categorical values.</li>
<li>Selecting - trimming the dataframe down to specific columns that you want to analyse or display.</li>
<li>Transforming - pivoting, unpivoting and other operations to unpack complex structures such as arrays and dictionaries.</li>
<li>Joining - joining dataframes based on a relationship and specifying a join strategy (e.g. an "inner join" or "left outer join").</li>
<li>Calculated columns - adding new columns which are derived from others in the dataframe.</li>
<li>Cleaning - a diverse range of expressions are available to help clean up data, some examples include:
<ul>
<li>Handling empty data - dropping nulls or using strategies such as forward filling to fill in data where it missing.</li>
<li>Dropping duplicates.</li>
<li>Adding unique IDs.</li>
</ul>
</li>
</ul>
<p>There are more specialised expressions which are generally organised under a specific namespace. For example <code>polars.Expr.str</code> is the namespace under which string based expressions are organised.</p>
<p>Polars expressions are functional abstractions over a Series, where a Series is an array of values with the same data type, e.g. <code>List[polars.Int64]</code>. They are often the contents of a specific column in your Polars dataframe, but they can also be created through other means (e.g. as a derived, intermediate result in a chain of expressions).</p>
<p>Each expression is elegantly simple: they take a Series as input and produce a Series as output. Because the input and output types are the same, expressions can be chained indefinitely, making them composable.</p>
<p>Ritchie Vink draws a compelling analogy: "just as Python's vocabulary is small (if, else, for, lists) yet can express anything through combination, Polars gives you a limited set of operations that combine to handle use cases the developers never anticipated. You learn a small API surface, then apply that knowledge everywhere."</p>
<p>With composable expressions, you stay within Polars' DSL. The engine can analyse, optimise, and parallelise your logic because it understands what you're doing.</p>
<p>In the examples below, we aim to show the flexibility and power of Polars expressions by applying them to the World Bank data based on common data wrangling scenarios.</p>
<h3 id="filtering-and-selecting">Filtering and selecting</h3>
<p>We've been asked to prepare an official list of countries to be used across our organisation as a "source of truth" across different types of analytics. This gives us an opportunity to show some of the most frequently used Polars functions: <code>filter</code>, <code>select</code> and <code>sort</code>.</p>
<p>The World Bank is deemed to be the source, but we spot that raw data for countries contains "aggregate" level results on top of data for individual countries. Therefore, we use <code>polars.DataFrame.filter</code> to filter the dataframe to only retain the country level data, using the <code>~</code> operator to negate the <code>.is_in</code> condition.</p>
<p>We then use <a href="https://docs.pola.rs/api/python/stable/reference/lazyframe/api/polars.LazyFrame.select.html"><code>polars.Dataframe.select</code></a> to select the columns we want to publish from the range of columns available in the source dataframe.</p>
<p>Finally, we use the <a href="https://docs.pola.rs/api/python/stable/reference/dataframe/api/polars.DataFrame.sort.html"><code>polars.DataFrame.sort</code></a> expression so it is ordered by country name.</p>
<p>We chain these operations using a pattern commonly seen in functional programming style. This enables Polars to see the end to end intent of the operation and optimise it accordingly. It also provides code which is easy to read and maintain.</p>
<p>Polars works well with modern visualisation libraries such as plotly. So we can bring our final dataset to life by projecting it onto a map of the World.</p>
<pre><code class="language-python"># Inspection of the "region" column to see unique values shows an "Aggregates" category which we want to filter out.
countries["region"].unique()
</code></pre>
<table>
<thead>
<tr>
<th>region</th>
</tr>
</thead>
<tbody>
<tr>
<td>str</td>
</tr>
<tr>
<td>Sub-Saharan Africa</td>
</tr>
<tr>
<td>Latin America &amp; Caribbean</td>
</tr>
<tr>
<td>South Asia</td>
</tr>
<tr>
<td>East Asia &amp; Pacific</td>
</tr>
<tr>
<td>North America</td>
</tr>
<tr>
<td>Aggregates</td>
</tr>
<tr>
<td>Europe &amp; Central Asia</td>
</tr>
<tr>
<td>Middle East, North Africa, Afg…</td>
</tr>
</tbody>
</table>
<pre><code class="language-python"># We don't want to publish all of the columns in the countries dataframe.
countries.columns
</code></pre>
<pre><code class="language-plaintext">    ['country_code',
     'iso2_code',
     'country_name',
     'region',
     'region_id',
     'income_level',
     'capital_city',
     'longitude',
     'latitude']
</code></pre>
<pre><code class="language-python">countries = (
    countries
    .filter(~pl.col("region").is_in(["Aggregates"]))  # Filter the data, using the ~ operator to negate the is_in condition so we exclude "Aggregates".
    .select(["country_code", "iso2_code", "country_name", "region", "capital_city", "longitude", "latitude"])  # Select only relevant columns.
    .sort(["country_name"])  # Sort by country name.
)
countries
</code></pre>
<table>
<thead>
<tr>
<th>country_code</th>
<th>iso2_code</th>
<th>country_name</th>
<th>region</th>
<th>capital_city</th>
<th>longitude</th>
<th>latitude</th>
</tr>
</thead>
<tbody>
<tr>
<td>str</td>
<td>str</td>
<td>str</td>
<td>str</td>
<td>str</td>
<td>f64</td>
<td>f64</td>
</tr>
<tr>
<td>AFG</td>
<td>AF</td>
<td>Afghanistan</td>
<td>Middle East, North Africa, Afg…</td>
<td>Kabul</td>
<td>69.1761</td>
<td>34.5228</td>
</tr>
<tr>
<td>ALB</td>
<td>AL</td>
<td>Albania</td>
<td>Europe &amp; Central Asia</td>
<td>Tirane</td>
<td>19.8172</td>
<td>41.3317</td>
</tr>
<tr>
<td>DZA</td>
<td>DZ</td>
<td>Algeria</td>
<td>Middle East, North Africa, Afg…</td>
<td>Algiers</td>
<td>3.05097</td>
<td>36.7397</td>
</tr>
<tr>
<td>ASM</td>
<td>AS</td>
<td>American Samoa</td>
<td>East Asia &amp; Pacific</td>
<td>Pago Pago</td>
<td>-170.691</td>
<td>-14.2846</td>
</tr>
<tr>
<td>AND</td>
<td>AD</td>
<td>Andorra</td>
<td>Europe &amp; Central Asia</td>
<td>Andorra la Vella</td>
<td>1.5218</td>
<td>42.5075</td>
</tr>
<tr>
<td>…</td>
<td>…</td>
<td>…</td>
<td>…</td>
<td>…</td>
<td>…</td>
<td>…</td>
</tr>
<tr>
<td>VIR</td>
<td>VI</td>
<td>Virgin Islands (U.S.)</td>
<td>Latin America &amp; Caribbean</td>
<td>Charlotte Amalie</td>
<td>-64.8963</td>
<td>18.3358</td>
</tr>
<tr>
<td>PSE</td>
<td>PS</td>
<td>West Bank and Gaza</td>
<td>Middle East, North Africa, Afg…</td>
<td>null</td>
<td>null</td>
<td>null</td>
</tr>
<tr>
<td>YEM</td>
<td>YE</td>
<td>Yemen, Rep.</td>
<td>Middle East, North Africa, Afg…</td>
<td>Sana'a</td>
<td>44.2075</td>
<td>15.352</td>
</tr>
<tr>
<td>ZMB</td>
<td>ZM</td>
<td>Zambia</td>
<td>Sub-Saharan Africa</td>
<td>Lusaka</td>
<td>28.2937</td>
<td>-15.3982</td>
</tr>
<tr>
<td>ZWE</td>
<td>ZW</td>
<td>Zimbabwe</td>
<td>Sub-Saharan Africa</td>
<td>Harare</td>
<td>31.0672</td>
<td>-17.8312</td>
</tr>
</tbody>
</table>
<pre><code class="language-python"># Display the results on a geographical scatter plot.
fig = px.scatter_geo(
    countries,
    lat="latitude",
    lon="longitude",
    hover_name="country_name",
    color="region",           # Color points by region
    projection="natural earth",
    title="World Bank Data - Country Locations"
)

fig.show()
</code></pre>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/01/chart_1.png"><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/01/chart_1.png" alt="Plotly chart showing countries on a map of the world" title="Plotly chart showing countries on a map of the world" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/01/chart_1.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/01/chart_1.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/01/chart_1.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/01/chart_1.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></a></p>
<h3 id="calculated-columns">Calculated columns</h3>
<p>In the next example, we want to answer a simple question "How has the population (in millions) of the United Kingdom grown year on year over the last 50 years?".</p>
<p>In the dataset we loaded from Parquet, We have the population data in a column called <code>WB_WDI_SP_POP_TOTL</code>.</p>
<p>We need to filter this data in two dimensions:</p>
<ul>
<li>We only want data for the "United Kingdom"</li>
<li>We only want data for the last 50 years.</li>
</ul>
<p>We use <a href="https://docs.pola.rs/api/python/stable/reference/dataframe/api/polars.DataFrame.with_columns.html"><code>polars.DataFrame.with_columns</code></a> to add a new calculated column which converts the results into millions (making it easier for humans to reason with the data).</p>
<p>Next we need to sort the data in ascending order by year so we can then use the <a href="https://docs.pola.rs/api/python/stable/reference/series/api/polars.Series.shift.html"><code>polars.Series.shift</code></a> expression to enable the percentage change year on year to be calculated as a new column.</p>
<p>We then add a column using the <a href="https://docs.pola.rs/api/python/stable/reference/expressions/api/polars.when.html"><code>polars.when</code></a> function to create a final new column "color" which is set to a literal value of "green" when positive population growth, otherwise it is "red".</p>
<pre><code class="language-python">NUMBER_OF_YEARS = 50
</code></pre>
<pre><code class="language-python">uk_population = (
    metrics
    .filter((pl.col("country_name") == "United Kingdom") &amp; (pl.col("year") &gt; (int(metrics["year"].max()) - NUMBER_OF_YEARS)))  # Filter in one step based on country name and year.
    .with_columns((pl.col("WB_WDI_SP_POP_TOTL") / 1000000).alias("population_in_millions"))  # Add a new column for population in millions.
    .sort("year", descending=False)
    .with_columns(
        (((pl.col("population_in_millions") - pl.col("population_in_millions").shift(1))) / pl.col("population_in_millions").shift(1) * 100)
        .alias("population_change_percentage")  # Add new column for population change percentage.
    )
    .with_columns(
        pl.when(pl.col("population_change_percentage") &gt; 0)
        .then(pl.lit("green"))
        .otherwise(pl.lit("red"))
        .alias("color")  # Add new column for color based on population change.
    )
    .select(["year", "population_in_millions", "population_change_percentage", "color"])
)
</code></pre>
<pre><code class="language-python">uk_population
</code></pre>
<table>
<thead>
<tr>
<th>year</th>
<th>population_in_millions</th>
<th>population_change_percentage</th>
<th>color</th>
</tr>
</thead>
<tbody>
<tr>
<td>i64</td>
<td>f64</td>
<td>f64</td>
<td>str</td>
</tr>
<tr>
<td>1975</td>
<td>56.2258</td>
<td>null</td>
<td>red</td>
</tr>
<tr>
<td>1976</td>
<td>56.211968</td>
<td>-0.024601</td>
<td>red</td>
</tr>
<tr>
<td>1977</td>
<td>56.193492</td>
<td>-0.032868</td>
<td>red</td>
</tr>
<tr>
<td>1978</td>
<td>56.196504</td>
<td>0.00536</td>
<td>green</td>
</tr>
<tr>
<td>1979</td>
<td>56.246951</td>
<td>0.089769</td>
<td>green</td>
</tr>
<tr>
<td>…</td>
<td>…</td>
<td>…</td>
<td>…</td>
</tr>
<tr>
<td>2020</td>
<td>66.744</td>
<td>0.169591</td>
<td>green</td>
</tr>
<tr>
<td>2021</td>
<td>66.984</td>
<td>0.359583</td>
<td>green</td>
</tr>
<tr>
<td>2022</td>
<td>67.604</td>
<td>0.925594</td>
<td>green</td>
</tr>
<tr>
<td>2023</td>
<td>68.492</td>
<td>1.313532</td>
<td>green</td>
</tr>
<tr>
<td>2024</td>
<td>69.226</td>
<td>1.071658</td>
<td>green</td>
</tr>
</tbody>
</table>
<pre><code class="language-python"># Create subplots with 2 rows and 1 column
fig = make_subplots(
    rows=2, cols=1,
    shared_xaxes=True,
    vertical_spacing=0.1,  # Space between charts
    row_heights=[0.7, 0.3],  # 70% height for main chart, 30% for bar chart
    subplot_titles=("Total Population (Millions)", "Year-on-Year Growth (%)")
)

# Top Chart: Absolute Population (Line + Markers)
fig.add_trace(
    go.Scatter(
        x=uk_population["year"],
        y=uk_population["population_in_millions"],
        mode="lines+markers",
        name="Population",
        line=dict(width=3)
    ),
    row=1, col=1
)

# Bottom Chart: Percentage Change (Bar)
fig.add_trace(
    go.Bar(
        x=uk_population["year"],
        y=uk_population["population_change_percentage"],
        marker_color=uk_population["color"],  # Use the calculated red/green column
        name="Change %"
    ),
    row=2, col=1
)

# Update layout configuration
fig.update_layout(
    title_text="United Kingdom Population Analysis (Last 50 Years)",
    showlegend=False
)

fig.show()
</code></pre>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/01/chart_2.png"><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/01/chart_2.png" alt="Chart showing changes in UK population over the last 50 years" title="Chart showing changes in UK population over the last 50 years" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/01/chart_2.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/01/chart_2.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/01/chart_2.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/01/chart_2.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></a></p>
<h3 id="unpacking-complex-types">Unpacking complex types</h3>
<p>In the data loading examples above, we loaded a set of JSON files (one per country) that contained metrics for each country as an array of complex type.</p>
<p>Polars provides two useful functions to enable you unpack this type of data:</p>
<ul>
<li><p>The <a href="https://docs.pola.rs/api/python/stable/reference/dataframe/api/polars.DataFrame.unnest.html"><code>polars.DataFrame.unnest</code></a> function is used to decompose struct columns, creating one new column for each of their fields. For example the column <code>country_info</code> contains a dictionary-like structure with data such as <code>{"country_name": "Argentina", "region": "Latin America &amp; Caribbean ", "income_level": "Upper middle income", "capital_city": "Buenos Aires", "longitude": "-34.6118", "latitude": "-58.4173"}</code>, when we call the <code>unnest</code> operation on this column, it creates 4 new columns (<code>country_name</code>, <code>region</code>, <code>income_level</code>, <code>capital_city</code>, <code>longitude</code> and <code>latitude</code>) and populates these columns with the values in those respective elements of the dictionary-like structure.</p>
</li>
<li><p>The <a href="https://docs.pola.rs/api/python/stable/reference/dataframe/api/polars.DataFrame.explode.html"><code>polars.DataFrame.explode</code></a> method is used to unpack columns which contain an array (list) object. Exploding the data by creating a row for each unique value in the array.</p>
</li>
</ul>
<p>The net result is we can flatten out the nested data into a tabular form, making it ready for downstream analytics.</p>
<pre><code class="language-python">json_metrics = (
    json_metrics
    .explode("indicators")  # Turn list of indicators into individual row for each indicator
    .unnest("indicators")  # Unpack the indicators object into individual components
    .explode("data_points")  # Explode the data_points (a list of {"year: XXXX, "value": XXXX}) into individual rows
    .unnest("data_points")  # Unpack the datapoints into separate columns for `year` and `value`
    .unnest("country_info")  # Unpack the country_info object into individual columns
)
json_metrics
</code></pre>
<table>
<thead>
<tr>
<th>country_code</th>
<th>country_name</th>
<th>region</th>
<th>income_level</th>
<th>capital_city</th>
<th>latitude</th>
<th>longitude</th>
<th>indicator_code</th>
<th>indicator_name</th>
<th>aggregation_method</th>
<th>year</th>
<th>value</th>
</tr>
</thead>
<tbody>
<tr>
<td>str</td>
<td>str</td>
<td>str</td>
<td>str</td>
<td>str</td>
<td>str</td>
<td>str</td>
<td>str</td>
<td>str</td>
<td>str</td>
<td>i64</td>
<td>f64</td>
</tr>
<tr>
<td>ALB</td>
<td>Albania</td>
<td>Europe &amp; Central Asia</td>
<td>Upper middle income</td>
<td>Tirane</td>
<td>41.3317</td>
<td>19.8172</td>
<td>WB_WDI_NY_GDP_PCAP_CD</td>
<td>GDP per capita (current US$)</td>
<td>Weighted average</td>
<td>2019</td>
<td>6069.439031</td>
</tr>
<tr>
<td>ALB</td>
<td>Albania</td>
<td>Europe &amp; Central Asia</td>
<td>Upper middle income</td>
<td>Tirane</td>
<td>41.3317</td>
<td>19.8172</td>
<td>WB_WDI_NY_GDP_PCAP_CD</td>
<td>GDP per capita (current US$)</td>
<td>Weighted average</td>
<td>2004</td>
<td>2446.909499</td>
</tr>
<tr>
<td>ALB</td>
<td>Albania</td>
<td>Europe &amp; Central Asia</td>
<td>Upper middle income</td>
<td>Tirane</td>
<td>41.3317</td>
<td>19.8172</td>
<td>WB_WDI_NY_GDP_PCAP_CD</td>
<td>GDP per capita (current US$)</td>
<td>Weighted average</td>
<td>2013</td>
<td>4542.929036</td>
</tr>
<tr>
<td>ALB</td>
<td>Albania</td>
<td>Europe &amp; Central Asia</td>
<td>Upper middle income</td>
<td>Tirane</td>
<td>41.3317</td>
<td>19.8172</td>
<td>WB_WDI_NY_GDP_PCAP_CD</td>
<td>GDP per capita (current US$)</td>
<td>Weighted average</td>
<td>2000</td>
<td>1160.420471</td>
</tr>
<tr>
<td>ALB</td>
<td>Albania</td>
<td>Europe &amp; Central Asia</td>
<td>Upper middle income</td>
<td>Tirane</td>
<td>41.3317</td>
<td>19.8172</td>
<td>WB_WDI_NY_GDP_PCAP_CD</td>
<td>GDP per capita (current US$)</td>
<td>Weighted average</td>
<td>2008</td>
<td>4498.504868</td>
</tr>
<tr>
<td>…</td>
<td>…</td>
<td>…</td>
<td>…</td>
<td>…</td>
<td>…</td>
<td>…</td>
<td>…</td>
<td>…</td>
<td>…</td>
<td>…</td>
<td>…</td>
</tr>
<tr>
<td>ZAF</td>
<td>South Africa</td>
<td>Sub-Saharan Africa</td>
<td>Upper middle income</td>
<td>Pretoria</td>
<td>-25.746</td>
<td>28.1871</td>
<td>WB_WDI_SL_UEM_TOTL_ZS</td>
<td>Unemployment, total (% of tota…</td>
<td>Weighted average</td>
<td>2000</td>
<td>22.714</td>
</tr>
<tr>
<td>ZAF</td>
<td>South Africa</td>
<td>Sub-Saharan Africa</td>
<td>Upper middle income</td>
<td>Pretoria</td>
<td>-25.746</td>
<td>28.1871</td>
<td>WB_WDI_SL_UEM_TOTL_ZS</td>
<td>Unemployment, total (% of tota…</td>
<td>Weighted average</td>
<td>1999</td>
<td>22.791</td>
</tr>
<tr>
<td>ZAF</td>
<td>South Africa</td>
<td>Sub-Saharan Africa</td>
<td>Upper middle income</td>
<td>Pretoria</td>
<td>-25.746</td>
<td>28.1871</td>
<td>WB_WDI_SL_UEM_TOTL_ZS</td>
<td>Unemployment, total (% of tota…</td>
<td>Weighted average</td>
<td>1995</td>
<td>22.647</td>
</tr>
<tr>
<td>ZAF</td>
<td>South Africa</td>
<td>Sub-Saharan Africa</td>
<td>Upper middle income</td>
<td>Pretoria</td>
<td>-25.746</td>
<td>28.1871</td>
<td>WB_WDI_SL_UEM_TOTL_ZS</td>
<td>Unemployment, total (% of tota…</td>
<td>Weighted average</td>
<td>1991</td>
<td>23.002</td>
</tr>
<tr>
<td>ZAF</td>
<td>South Africa</td>
<td>Sub-Saharan Africa</td>
<td>Upper middle income</td>
<td>Pretoria</td>
<td>-25.746</td>
<td>28.1871</td>
<td>WB_WDI_SL_UEM_TOTL_ZS</td>
<td>Unemployment, total (% of tota…</td>
<td>Weighted average</td>
<td>1996</td>
<td>22.48</td>
</tr>
</tbody>
</table>
<pre><code class="language-python">json_metrics["indicator_code"].unique()
</code></pre>
<table>
<thead>
<tr>
<th>indicator_code</th>
</tr>
</thead>
<tbody>
<tr>
<td>str</td>
</tr>
<tr>
<td>WB_WDI_SP_POP_TOTL</td>
</tr>
<tr>
<td>WB_WDI_GC_DOD_TOTL_GD_ZS</td>
</tr>
<tr>
<td>WB_WDI_EG_USE_PCAP_KG_OE</td>
</tr>
<tr>
<td>WB_WDI_SE_ADT_LITR_ZS</td>
</tr>
<tr>
<td>WB_WDI_NY_GDP_MKTP_CD</td>
</tr>
<tr>
<td>WB_WDI_NY_GDP_PCAP_CD</td>
</tr>
<tr>
<td>WB_WDI_SL_UEM_TOTL_ZS</td>
</tr>
<tr>
<td>WB_WDI_SP_DYN_LE00_IN</td>
</tr>
</tbody>
</table>
<h2 id="complex-transformation-leveraging-duckdb-and-polars">Complex transformation, leveraging DuckDB and Polars</h2>
<p>When you pair DuckDB with Polars, you get the best of both worlds:</p>
<ul>
<li><strong>High-Performance SQL:</strong> Use DuckDB's fast SQL engine to perform initial filtering, aggregation, and data manipulation at the database level.</li>
<li><strong>Expressive DataFrame API:</strong> Load the results directly into a Polars DataFrame to leverage its powerful and expressive API for more complex transformations and analysis.</li>
</ul>
<p>In this example, we are first going to use a DuckDB prepare the data through a more complex query which joins two tables and filters the data through a <code>WHERE</code> clause.</p>
<pre><code class="language-python">with duckdb.connect(f'{DUCKDB_PATH}', read_only=True) as duckdb_connection:
    
    duckdb_query_results = duckdb_connection.sql(
        """
        SELECT d.country_code, c.country_name, c.region, d.year, d.indicator_code, d.value 
        FROM data d
        JOIN countries c ON d.country_code = c.country_code
        WHERE d.indicator_code IN ('WB_WDI_SP_POP_TOTL', 'WB_WDI_SP_DYN_LE00_IN', 'WB_WDI_NY_GDP_PCAP_CD')
        """
        ).pl()

duckdb_query_results.head(3)
</code></pre>
<table>
<thead>
<tr>
<th>country_code</th>
<th>country_name</th>
<th>region</th>
<th>year</th>
<th>indicator_code</th>
<th>value</th>
</tr>
</thead>
<tbody>
<tr>
<td>str</td>
<td>str</td>
<td>str</td>
<td>i64</td>
<td>str</td>
<td>f64</td>
</tr>
<tr>
<td>VNM</td>
<td>Viet Nam</td>
<td>East Asia &amp; Pacific</td>
<td>2011</td>
<td>WB_WDI_NY_GDP_PCAP_CD</td>
<td>1950.925042</td>
</tr>
<tr>
<td>VNM</td>
<td>Viet Nam</td>
<td>East Asia &amp; Pacific</td>
<td>2018</td>
<td>WB_WDI_NY_GDP_PCAP_CD</td>
<td>3222.310031</td>
</tr>
<tr>
<td>GBR</td>
<td>United Kingdom</td>
<td>Europe &amp; Central Asia</td>
<td>2016</td>
<td>WB_WDI_NY_GDP_PCAP_CD</td>
<td>41257.908555</td>
</tr>
</tbody>
</table>
<p>Next we are going to use a <a href="https://docs.pola.rs/api/python/stable/reference/dataframe/api/polars.DataFrame.pivot.html"><code>polars.DataFrame.pivot</code></a> operation to transform the shape of the dataframe and get the data ready to plot on a chart.</p>
<pre><code class="language-python">world_wealth_and_health = (
    duckdb_query_results
    .pivot(
        on=["indicator_code"],
        index=["country_code", "country_name", "region", "year"],
        values="value"
        )
    .rename(
        {
            "WB_WDI_NY_GDP_PCAP_CD": "gdp_usd_per_capita",
            "WB_WDI_SP_DYN_LE00_IN": "life_expectancy",
            "WB_WDI_SP_POP_TOTL": "population"
        }
        )
    .drop_nulls(subset=["gdp_usd_per_capita", "life_expectancy", "population"])  # Drop rows with nulls in any of the key metrics
    .with_columns(
        [
            (pl.col("population") / 1000000).round(2).alias("population_in_millions"),
        ]
    )
    .sort(["year", "country_code"])
)
</code></pre>
<pre><code class="language-python">world_wealth_and_health.head(3)
</code></pre>
<table>
<thead>
<tr>
<th>country_code</th>
<th>country_name</th>
<th>region</th>
<th>year</th>
<th>gdp_usd_per_capita</th>
<th>life_expectancy</th>
<th>population</th>
<th>population_in_millions</th>
</tr>
</thead>
<tbody>
<tr>
<td>str</td>
<td>str</td>
<td>str</td>
<td>i64</td>
<td>f64</td>
<td>f64</td>
<td>f64</td>
<td>f64</td>
</tr>
<tr>
<td>ARG</td>
<td>Argentina</td>
<td>Latin America &amp; Caribbean</td>
<td>1970</td>
<td>1322.714542</td>
<td>65.647</td>
<td>2.3878327e7</td>
<td>23.88</td>
</tr>
<tr>
<td>AUS</td>
<td>Australia</td>
<td>East Asia &amp; Pacific</td>
<td>1970</td>
<td>3309.763063</td>
<td>71.018537</td>
<td>1.2507e7</td>
<td>12.51</td>
</tr>
<tr>
<td>BGD</td>
<td>Bangladesh</td>
<td>South Asia</td>
<td>1970</td>
<td>130.218161</td>
<td>42.667</td>
<td>6.9058894e7</td>
<td>69.06</td>
</tr>
</tbody>
</table>
<p>Finally we chart the results to show the snail trail of each country over time on a two dimensional scatter chart.</p>
<pre><code class="language-python">fig = px.scatter(
    world_wealth_and_health,
    x="gdp_usd_per_capita",
    y="life_expectancy",
    animation_frame="year",
    animation_group="country_name",
    size="population_in_millions",
    color="region",
    hover_name="country_name",
    log_x=True,
    size_max=55,
    range_x=[100, 100000],
    range_y=[25, 90],
    title="World Wealth and Health Over Time",
    labels={
        "gdp_usd_per_capita": "Wealth (GDP Per Capita in USD)",
        "life_expectancy": "Health (Life Expectancy in Years)"
    }
)
fig.show()
</code></pre>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/01/chart_3.png"><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/01/chart_3.png" alt="Chart showing scatter plot or individual countries based on wealth (GDP, log scale, x-axis) versus wealth (lifeexpectancy, y-axis)" title="Chart showing scatter plot or individual countries based on wealth (GDP, log scale, x-axis) versus wealth (lifeexpectancy, y-axis)" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/01/chart_3.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/01/chart_3.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/01/chart_3.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/01/chart_3.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></a></p>
<h2 id="lazy-loading">Lazy loading</h2>
<pre><code class="language-python"># Read CSV using lazy frame
data = pl.scan_parquet(PARQUET_FOLDER, hive_partitioning=True)
</code></pre>
<pre><code class="language-python"># The `scan_parquet returns a lazy frame not the data,  But it does inspect the files.
data
</code></pre>
<p><i>naive plan: (run <b>LazyFrame.explain(optimized=True)</b> to see the optimized plan)</i>
</p><p></p>
<div>Parquet SCAN [../data/world_bank/parquet/year=1970/af92515fa293459c9aa99c6433ee2cda.parquet, ... 54 other sources]<p></p>PROJECT 11/12 COLUMNS<p></p>ESTIMATED ROWS: 1760</div><p></p>
<pre><code class="language-python"># We can inspect the schema without loading data
data.collect_schema()
</code></pre>
<pre><code>Schema([('country_code', String),
        ('year', Int64),
        ('WB_WDI_EG_USE_PCAP_KG_OE', Float64),
        ('WB_WDI_GC_DOD_TOTL_GD_ZS', Float64),
        ('WB_WDI_NY_GDP_MKTP_CD', Float64),
        ('WB_WDI_NY_GDP_PCAP_CD', Float64),
        ('WB_WDI_SE_ADT_LITR_ZS', Float64),
        ('WB_WDI_SL_UEM_TOTL_ZS', Float64),
        ('WB_WDI_SP_DYN_LE00_IN', Float64),
        ('WB_WDI_SP_POP_TOTL', Float64),
        ('country_name', String),
        ('region', String)])
</code></pre>
<pre><code class="language-python"># Start to build up some operations on the lazy frame
data = (
    data
    .rename({"WB_WDI_SP_POP_TOTL": "population"})  # Rename the value column to population
)
</code></pre>
<pre><code class="language-python"># We want use 1980 as the base year for our analysis
data = data.filter(pl.col("year") &gt;= 1980)
</code></pre>
<pre><code class="language-python"># Normalise each country's population so it has a maximum of 1 for all years since 1980
data = (
    data
    .with_columns(
        pl.col("population")
        .max()
        .over("country_code")
        .alias("max_population")  # Get max population per country
    )
    .with_columns(
        (pl.col("population") / pl.col("max_population")).alias("normalized_population")  # Normalized population
    )
    .drop("max_population")  # Drop the intermediate column
)   
</code></pre>
<pre><code class="language-python"># Select the final set of columns we want to publish
data = data.select([
    "country_code",
    "country_name",
    "region",
    "year",
    "population",
    "normalized_population",
])
</code></pre>
<pre><code class="language-python"># We only want to see data for a selection of countries and from 1980 onwards
data = (
    data
    .filter(pl.col("country_code").is_in(["CHN", "UK", "ALB", "JPN", "NZL", "CAN"]))  # Filter for specific countries and years
)
</code></pre>
<p>At this stage we haven't executed the steps we have built up above. We compare the query plans generated by <code>explain(optimized=True)</code> versus <code>explain(optimized=False)</code> - each shows nested sets of steps in reverse order. A quick scan shows the following key differences:</p>
<p>The un-optimized plan follows the steps in the order we defined them above.</p>
<pre><code class="language-python"># The un-optimized plan shows all the steps we have built up above.
print(data.explain(optimized=False))
</code></pre>
<pre><code class="language-plaintext">FILTER col("country_code").is_in([["CHN", "UK", … "CAN"]])
FROM
  SELECT [col("country_code"), col("country_name"), col("region"), col("year"), col("population"), col("normalized_population")]
    SELECT [col("country_code"), col("year"), col("WB_WDI_EG_USE_PCAP_KG_OE"), col("WB_WDI_GC_DOD_TOTL_GD_ZS"), col("WB_WDI_NY_GDP_MKTP_CD"), col("WB_WDI_NY_GDP_PCAP_CD"), col("WB_WDI_SE_ADT_LITR_ZS"), col("WB_WDI_SL_UEM_TOTL_ZS"), col("WB_WDI_SP_DYN_LE00_IN"), col("population"), col("country_name"), col("region"), col("normalized_population")]
        WITH_COLUMNS:
        [[(col("population")) / (col("max_population"))].alias("normalized_population")] 
          WITH_COLUMNS:
          [col("population").max().over([col("country_code")]).alias("max_population")] 
          FILTER [(col("year")) &gt;= (1980)]
          FROM
            SELECT [col("country_code"), col("year"), col("WB_WDI_EG_USE_PCAP_KG_OE"), col("WB_WDI_GC_DOD_TOTL_GD_ZS"), col("WB_WDI_NY_GDP_MKTP_CD"), col("WB_WDI_NY_GDP_PCAP_CD"), col("WB_WDI_SE_ADT_LITR_ZS"), col("WB_WDI_SL_UEM_TOTL_ZS"), col("WB_WDI_SP_DYN_LE00_IN"), col("WB_WDI_SP_POP_TOTL").alias("population"), col("country_name"), col("region")]
              Parquet SCAN [../data/world_bank/parquet/year=1970/af92515fa293459c9aa99c6433ee2cda.parquet, ... 54 other sources]
              PROJECT 11/12 COLUMNS
              ESTIMATED ROWS: 1760
</code></pre>
<p>The optimized plan shows less steps and a different ordering of operations because Polars has applied multiple levels of optimization:</p>
<ul>
<li><strong>Predicate Pushdown</strong>: Look for <code>SELECTION</code> in the scan node. Polars has pushed the filter logic down to the data access layer. Instead of reading all rows into memory and <em>then</em> filtering them, it applies the filter <em>during</em> the scan, discarding non-matching rows immediately.</li>
<li><strong>Projection Pushdown</strong>: In the Optimized plan, look for <code>PROJECT */* COLUMNS</code>. Polars analysed your query and determined exactly which columns are needed. It will strictly only read those specific columns from disk, ignoring the rest to save memory and I/O.</li>
<li><strong>Intelligent Scan Optimisation</strong>: This combines <strong>Partition Pruning</strong> and <strong>Parquet Statistics</strong>.
<ul>
<li><strong>Partition Pruning</strong>: Because we used <code>hive_partitioning=True</code> and filtered on <code>year</code>, Polars checks the folder names first. It completely skips opening files for years 1960-1979, only reading files that match the filter.</li>
<li><strong>Row Group Statistics</strong>: Unique to Parquet (vs CSV/JSON), these files contain metadata with min/max values for chunks of data ("Row Groups"). If we filtered on a data column, Polars would check these stats and skip reading entire chunks of the file if they couldn't possibly contain matching data.</li>
</ul>
</li>
</ul>
<pre><code class="language-python"># The optimized plan shows how Polars will execute the query efficiently.
print(data.explain())
</code></pre>
<pre><code class="language-plaintext">simple π 6/6 ["country_code", "country_name", ... 4 other columns]
    WITH_COLUMNS:
    [[(col("population")) / (col("max_population"))].alias("normalized_population")] 
      WITH_COLUMNS:
      [col("population").max().over([col("country_code")]).alias("max_population")] 
      SELECT [col("country_code"), col("year"), col("WB_WDI_SP_POP_TOTL").alias("population"), col("country_name"), col("region")]
        Parquet SCAN [../data/world_bank/parquet/year=1980/77399e87ba29443cb65b5a3fff361036.parquet, ... 44 other sources]
        PROJECT 5/12 COLUMNS
        SELECTION: [([(col("year")) &gt;= (1980)]) &amp; (col("country_code").is_in([["CHN", "UK", … "CAN"]]))]
        ESTIMATED ROWS: 1440
</code></pre>
<p>Now we can actually run the end to end logic to ingest the data and perform the optimised chain of operations on it by calling the <a href="https://docs.pola.rs/api/python/stable/reference/lazyframe/api/polars.LazyFrame.collect.html"><code>polars.lazyframe.collect</code></a> method.</p>
<pre><code class="language-python"># Get the data by executing `.collect()` on the lazy frame
data.collect()
</code></pre>
<table>
<thead>
<tr>
<th>country_code</th>
<th>country_name</th>
<th>region</th>
<th>year</th>
<th>population</th>
<th>normalized_population</th>
</tr>
</thead>
<tbody>
<tr>
<td>str</td>
<td>str</td>
<td>str</td>
<td>i64</td>
<td>f64</td>
<td>f64</td>
</tr>
<tr>
<td>NZL</td>
<td>New Zealand</td>
<td>East Asia &amp; Pacific</td>
<td>1980</td>
<td>3.1129e6</td>
<td>0.588728</td>
</tr>
<tr>
<td>JPN</td>
<td>Japan</td>
<td>East Asia &amp; Pacific</td>
<td>1980</td>
<td>1.16807e8</td>
<td>0.912056</td>
</tr>
<tr>
<td>CAN</td>
<td>Canada</td>
<td>North America</td>
<td>1980</td>
<td>2.4515667e7</td>
<td>0.593764</td>
</tr>
<tr>
<td>CHN</td>
<td>China</td>
<td>East Asia &amp; Pacific</td>
<td>1980</td>
<td>9.81235e8</td>
<td>0.694749</td>
</tr>
<tr>
<td>ALB</td>
<td>Albania</td>
<td>Europe &amp; Central Asia</td>
<td>1980</td>
<td>2.671997e6</td>
<td>0.813012</td>
</tr>
<tr>
<td>…</td>
<td>…</td>
<td>…</td>
<td>…</td>
<td>…</td>
<td>…</td>
</tr>
<tr>
<td>CAN</td>
<td>Canada</td>
<td>North America</td>
<td>2024</td>
<td>4.1288599e7</td>
<td>1.0</td>
</tr>
<tr>
<td>JPN</td>
<td>Japan</td>
<td>East Asia &amp; Pacific</td>
<td>2024</td>
<td>1.23975371e8</td>
<td>0.968028</td>
</tr>
<tr>
<td>NZL</td>
<td>New Zealand</td>
<td>East Asia &amp; Pacific</td>
<td>2024</td>
<td>5.2875e6</td>
<td>1.0</td>
</tr>
<tr>
<td>ALB</td>
<td>Albania</td>
<td>Europe &amp; Central Asia</td>
<td>2024</td>
<td>2.377128e6</td>
<td>0.723292</td>
</tr>
<tr>
<td>CHN</td>
<td>China</td>
<td>East Asia &amp; Pacific</td>
<td>2024</td>
<td>1.4090e9</td>
<td>0.997603</td>
</tr>
</tbody>
</table>
<p>This may seem like a lot of additional steps for this relatively small amount of demo data we are using. But as you scale up to many Gigabytes with thousands of Parquet files in a lakehouse architecture, this approach will generate significant performance gains.</p>
<p>Final step is to bring our analysis to life with a chart.</p>
<pre><code class="language-python"># Plot the result one line per country showing normalized population over time
line_chart = px.line(
    data.collect(),
    x="year",
    y="normalized_population",
    color="country_name",
    title="Normalized Population Growth Since 1980",
    markers=True
)
line_chart.show()
</code></pre>
<h2 id="streaming-execution">Streaming Execution</h2>
<p>In the example above, we used <code>.collect()</code> to materialize the final result into memory. For the amount of data we are working with here, this is perfectly fine.</p>
<p>However, one of Polars' most powerful features is its <strong>Streaming Engine</strong>.</p>
<p>If your dataset is larger than your machine's available RAM, a standard <code>.collect()</code> would result in an "Out of Memory" (OOM) error. By simply passing the <code>streaming=True</code> argument, you instruct Polars to process the data in batches.</p>
<p>It effectively pipelines the data processing, reading a chunk of data, processing it, and keeping only the results needed (e.g. the aggregated counts or the filtered rows) before moving on to the next chunk.</p>
<p>This allows you to process 100GB+ datasets on a standard laptop!</p>
<h2 id="conclusion">Conclusion</h2>
<p>Hopefully, these worked examples have given you a flavour of Polars and provided some useful tips for applying it to your own use cases.</p>
<p>Throughout this notebook, we've demonstrated the key pillars that make Polars a game-changer for data engineering in Python, enabling you to express complex data transformations in a clear, concise, and performant way:</p>
<ul>
<li><strong>Seamless Data Ingestion:</strong> First-class support for common formats like Parquet, CSV, and JSON makes loading data trivial.</li>
<li><strong>Expressive, Composable API:</strong> The functional API design allows you to build complex logic that remains readable and maintainable.</li>
<li><strong>Performance by Design:</strong> Under the hood, the Rust-based engine leverages vectorized execution and parallelization to handle heavy data wrangling tasks effortlessly.</li>
<li><strong>Lazy Evaluation:</strong> By switching to <code>LazyFrames</code>, you hand control to the Query Optimizer. This unlocks techniques like <strong>Predicate Pushdown</strong> and <strong>Projection Pushdown</strong>, which can deliver huge performance gains just by letting Polars decide <em>how</em> to execute your query.</li>
<li><strong>Streaming Execution:</strong> The <code>streaming=True</code> option breaks the memory barrier, allowing you to process datasets larger than your machine's RAM without needing a cluster.</li>
</ul>
<p>As you become more familiar with Polars' capabilities, you'll find that it allows you to handle increasingly complex data tasks with elegance and efficiency. The expression-based API, lazy evaluation, and thoughtful design make it a powerful tool for modern data analysis.</p>
<p>This is Part 3 of our Adventures in Polars series:</p>
<ul>
<li><strong>Part 1: <a href="https://endjin.com/blog/polars-faster-pipelines-simpler-infrastructure-happier-engineers">Why Polars Matters</a></strong>  -  The Decision Makers Guide for Polars.</li>
<li><strong>Part 2: <a href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast">What Makes Polars So Scalable and Fast?</a></strong>  -  The technical deep-dive: lazy evaluation, query optimisation, parallelism, and the Rust foundation.</li>
<li><strong>Part 4: <a href="https://endjin.com/blog/polars-workloads-on-microsoft-fabric">Polars Workloads on Microsoft Fabric</a></strong>  -  Running Polars on Fabric with OneLake integration.</li>
</ul>
<hr>
<p><em>What data analysis tasks have you tackled with Polars? Have you found particularly elegant solutions to common problems? Share your experiences in the comments below!</em></p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Adventures in Polars</h3>
        <span class="series-toc__count">4 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/polars-faster-pipelines-simpler-infrastructure-happier-engineers" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Why Polars Matters</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">What Makes Polars So Scalable and Fast?</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">3.</span>
                <span class="series-toc__part-title">Code Examples</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/polars-workloads-on-microsoft-fabric" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Polars Workloads on Fabric</span>
                </a>
            </li>
    </ol>
</aside>

<div class="footnotes">
<hr>
<ol>
<li id="fn:1">
<p>quote from talk <a href="https://youtu.be/UwRlFtSd_-8">"What Polars does for you" presented at Europython Conference in 2023</a>.<a href="https://endjin.com/blog/practical-polars-code-examples-everyday-data-tasks#fnref:1" class="footnote-back-ref">↩</a></p>
</li>
</ol>
</div>]]></content:encoded>
    </item>
    <item>
      <title>Under the Hood: What Makes Polars So Scalable and Fast?</title>
      <description>Polars gets its speed from a strict type system, lazy evaluation, and automatic parallelism. Here's how each piece works under the hood.</description>
      <link>https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast</link>
      <guid isPermaLink="true">https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast</guid>
      <pubDate>Thu, 29 Jan 2026 05:32:00 GMT</pubDate>
      <category>Polars</category>
      <category>DataFrame</category>
      <category>Performance Optimization</category>
      <category>Rust</category>
      <category>Lazy Evaluation</category>
      <category>Query Optimization</category>
      <category>Parallel Processing</category>
      <category>Columnar Computing</category>
      <category>Microsoft Fabric</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/01/what-makes-polars-so-scalable-and-fast.png" />
      <dc:creator>Barry Smart</dc:creator>
      <content:encoded><![CDATA[<p>TL;DR: Polars' impressive performance (5-20x faster than Pandas) comes from multiple architectural innovations working together: a Rust foundation provides low-level performance and memory control; a columnar storage model optimizes analytical workloads; lazy evaluation enables a sophisticated query optimizer that can rearrange, combine, and streamline operations; parallel execution automatically distributes work across CPU cores; and vectorized processing maximizes modern CPU capabilities. By bringing database optimization techniques to DataFrame operations, Polars delivers exceptional performance while maintaining an elegant API.</p>
<p>In <a href="https://endjin.com/blog/polars-faster-pipelines-simpler-infrastructure-happier-engineers">our previous article</a>, we introduced Polars as a next-generation DataFrame library that's taking the Python data ecosystem by storm. We explored its origin story, key features, and how it fits into the broader data landscape.</p>
<p>Now, let's look under the hood to understand <em>how</em> Polars achieves its remarkable performance. This isn't just an academic exercise - understanding these mechanisms will help you write more efficient code, debug performance issues, and make informed decisions about when and how to use Polars in your projects.</p>
<p>Ritchie Vink, Polars' creator, often emphasizes that Polars' speed comes from multiple factors working together rather than a single performance trick. This philosophy mirrors the <a href="https://en.wikipedia.org/wiki/Dave_Brailsford">"aggregation of marginal gains" strategy championed by Sir Dave Brailsford</a>, who led the British Olympic cycling team to world dominance. Just as Brailsford believed that a 1% improvement in many small areas would cumulate in significant competitive advantage, Polars achieves its blazing speed not through a single breakthrough, but by meticulously optimizing a multitude of small details. Let's examine each of these factors in detail.</p>
<h2 id="the-origin-story-from-performance-challenge-to-dataframe-revolution">The Origin Story: From Performance Challenge to DataFrame Revolution</h2>
<p>Every successful open-source project has an origin story, and <a href="https://pola.rs/">Polars'</a> begins with <a href="https://www.ritchievink.com/">Ritchie Vink</a>.</p>
<p>In late 2019, while learning <a href="https://rust-lang.org/">Rust</a> (then an emerging systems programming language), Vink faced a practical problem: he needed to join two CSV files efficiently. Rather than setting up a database for this seemingly simple task, he decided to implement his own join algorithm in Rust.</p>
<p>When he benchmarked his implementation against popular <a href="https://www.python.org/">Python</a> package <a href="https://pandas.pydata.org/">Pandas</a>, the results were disappointing: his code was <em>slower</em>. For most developers, this might have been the end of the experiment. For Vink, it was the beginning of a journey.</p>
<p>"This unsatisfying result planted the seed of what would later become Polars," Vink explains<a id="fnref:1" href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast#fn:1" class="footnote-ref"><sup>1</sup></a>. The challenge sparked his curiosity: why was Pandas faster, and how could he improve his implementation?</p>
<p>As he dove deeper into database engines, algorithms, and performance optimization, his goals evolved. What began as a simple join algorithm grew into a DataFrame package for Rust, and eventually into a high-performance query engine designed to rival industry standards in the Python ecosystem.</p>
<p>The name "Polars" itself carries a playful significance - the polar bear representing something stronger than a panda (a nod to the incumbent Pandas library), with the "rs" suffix reflecting its Rust foundation.</p>
<p>In March 2021, Vink released <a href="https://pypi.org/project/polars/">Polars on PyPI</a>, initially as a research project. Its exceptional performance quickly gained attention, and by 2023, <a href="https://pola.rs/">Polars had grown into its own company</a>, with Vink at the helm as its creator and CEO.</p>
<h2 id="designed-for-analytical-workloads">Designed for Analytical Workloads</h2>
<p>Polars is specifically designed for analytical processing (OLAP) rather than transactional workloads (OLTP). This means it excels at operations common in data analysis:</p>
<ul>
<li>Aggregations across large datasets</li>
<li>Complex joins between tables</li>
<li>Filter operations that reduce large datasets</li>
<li>Transformations that reshape or derive new columns</li>
<li>Time series operations</li>
</ul>
<p>This analytical focus drives decisions throughout Polars' design, from its columnar storage (optimal for reading subsets of columns) to its execution model (optimized for scanning and processing large volumes of data).</p>
<h2 id="built-on-database-research">Built on Database Research</h2>
<p>Unlike many DataFrame libraries that evolved organically from array manipulation libraries (Pandas is built on NumPy), Polars applies decades of database research to DataFrame operations. This brings sophisticated query optimization techniques, columnar processing, and other database innovations directly to the Python data ecosystem.</p>
<p>Vink emphasizes this distinction: "Polars respects decades of relational database research"<a id="fnref:6" href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast#fn:2" class="footnote-ref"><sup>2</sup></a>. This isn't just marketing - it's reflected in Polars' architecture, from its query optimizer to its columnar storage to its expression system.</p>
<h2 id="the-rust-foundation-performance-from-first-principles">The Rust Foundation: Performance from First Principles</h2>
<p>At the core of Polars' performance advantage is its implementation in Rust, a systems programming language that offers several key advantages for a high-performance DataFrame library:</p>
<ol>
<li><strong>Zero-cost abstractions</strong> - Rust's compiler generates machine code that's as efficient as hand-written C, without the safety risks</li>
<li><strong>Fine-grained memory control</strong> -direct control over memory allocation and layout</li>
<li><strong>No garbage collection</strong> - predictable performance without GC pauses</li>
<li><strong>Memory safety guarantees</strong> - protection against common bugs like buffer overflows and use-after-free</li>
<li><strong>Fearless concurrency</strong> - safe parallelism without data races</li>
</ol>
<p>Unlike Pandas, which is built on a mix of Python, Cython, and C through NumPy, Polars is written entirely in Rust from the ground up. This means every performance-critical component - from memory management to algorithm implementation - can be optimized with low-level control.</p>
<h3 id="python-bindings-best-of-both-worlds">Python Bindings: Best of Both Worlds</h3>
<p>While Polars' core is Rust, it exposes a carefully designed Python API through bindings. This gives users the convenience and familiarity of Python with the performance of Rust:</p>
<pre><code class="language-python"># What you write in Python
result = df.select(
    pl.col("value").sum()
)
</code></pre>
<p>What actually happens:</p>
<ol>
<li>The Python code builds an abstract query plan</li>
<li>This plan is passed to the Rust engine (via bindings, within the same OS process)</li>
<li>The Rust engine optimizes the plan</li>
<li>The Rust engine executes the plan, releasing Python's Global Interpreter Lock (GIL) so it can run multi-threaded without Python involvement</li>
<li>Results are returned to Python as Arrow-formatted memory buffers: Python receives a pointer to Rust-managed memory, not a copy of the data</li>
</ol>
<p>The last stage above is important: because Arrow is a specification, different tools that conform to it can share data without serialization:</p>
<p>As Vink explains: "if you know that a process can deal with Arrow data you can say 'I have some memory laying around here, it's laid out according to the Arrow specification'  -  at that point you can say to another process 'this is the specification and this is the pointer to where the data is'. If you read this according to this specification you can use this data as-is without needing to serialize any data"<a id="fnref:2" href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast#fn:1" class="footnote-ref"><sup>1</sup></a></p>
<p>This is the zero-copy benefit. When Polars returns results to Python there's no conversion  -  just a pointer to Arrow-formatted memory.</p>
<p>This architecture minimizes the "Python tax"  -  performance-critical computation happens in Rust-managed memory and threads, while Python remains a thin orchestration layer. This is why using <code>apply</code> with Python lambdas is discouraged: it forces Polars to acquire the GIL, blocking parallel execution.</p>
<h2 id="columnar-architecture-designed-for-analytical-workloads">Columnar Architecture: Designed for Analytical Workloads</h2>
<p>Polars stores data in columnar format, conforming to the Apache Arrow specification. This architecture provides the <em>potential</em> for significant performance gains  -  but realising that potential requires an engine built to exploit it.</p>
<p><strong>What the columnar format enables:</strong></p>
<ul>
<li><strong>Better compression</strong>: Homogeneous data types stored contiguously compress more efficiently (see below)</li>
<li><strong>Selective I/O</strong>: Column-oriented storage makes it <em>possible</em> to read only needed columns</li>
<li><strong>Cache-friendly access</strong>: Contiguous memory layout <em>allows</em> efficient CPU cache utilisation</li>
<li><strong>SIMD potential</strong>: Homogeneous data <em>can</em> be processed with vectorised CPU instructions</li>
</ul>
<p><strong>What Polars adds on top:</strong></p>
<p>These benefits don't materialise automatically  -  they require an engine purpose-built to exploit them. As Vink emphasises: "We've written Polars from scratch  -  every compute is from scratch."<a id="fnref:7" href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast#fn:2" class="footnote-ref"><sup>2</sup></a> The Arrow specification provides the memory layout; Polars provides the query engine that makes it fast.</p>
<p>Let's compare row-based and columnar storage visually:</p>
<p><strong>Row-based storage (like traditional databases):</strong></p>
<pre><code class="language-plaintext">[Row 1: float1, string1, date1] -&gt; [Row 2: float2, string2, date2] -&gt; [Row 3: float3, string3, date3]
</code></pre>
<p><strong>Columnar storage (like Polars):</strong></p>
<pre><code class="language-plaintext">value: [float1, float2, float3]
name:  [string1, string2, string3]
date:  [date1, date2, date3]
</code></pre>
<p>When computing something like <code>SUM(value)</code>, a columnar system only needs to load the <code>value</code> array, while a row-based system loads all data, including unused columns.</p>
<h3 id="compression-benefits-with-columnar-storage">Compression Benefits with Columnar Storage</h3>
<p>The Apache Arrow columnar storage specification offers exceptional compression opportunities, particularly for columns with low cardinality (few unique values):</p>
<ol>
<li><p><strong>Dictionary encoding</strong>: For low-cardinality columns (like categories, countries, or status codes), values can be replaced with integer indexes into a dictionary of unique values:</p>
<pre><code class="language-plaintext">Original: ["USA", "Canada", "USA", "Mexico", "Canada", "USA"]
Dictionary: ["USA"(0), "Canada"(1), "Mexico"(2)]
Encoded: [0, 1, 0, 2, 1, 0] (much smaller than storing the strings)
</code></pre>
</li>
<li><p><a href="https://en.wikipedia.org/wiki/Run-length_encoding"><strong>RLE</strong></a>: For columns with repeated consecutive values, store the value and count:</p>
<pre><code class="language-plaintext">Original: [5, 5, 5, 5, 5, 7, 7, 7, 8, 8, 8, 8]
Encoded: [(5,5), (7,3), (8,4)] (value, count)
</code></pre>
</li>
<li><p><a href="https://en.wikipedia.org/wiki/Delta_encoding"><strong>Delta encoding</strong></a>: For monotonically increasing values (like timestamps or IDs), store differences:</p>
<pre><code class="language-plaintext">Original: [1000, 1005, 1010, 1015, 1020]
Encoded: [1000, 5, 5, 5, 5] (first value, then differences)
</code></pre>
</li>
</ol>
<p>These compression techniques are particularly effective because each column contains homogeneous data types. In row-based storage, compression across different data types is much less efficient.</p>
<h3 id="metadata-and-statistics-for-query-optimization">Metadata and Statistics for Query Optimization</h3>
<p>File formats like Parquet (frequently used with Polars) use columnar storage and additionally store column-level statistics that enable powerful optimization when reading data:</p>
<ol>
<li><p><strong>Min/max statistics</strong> - each chunk of column data stores minimum and maximum values, allowing for predicate pushdown:</p>
<pre><code class="language-python"># If a data chunk has max_value=50 and your query is:
query = pl.scan_parquet("data.parquet").filter(pl.col("value") &gt; 100)

# The entire chunk can be skipped without reading any data
</code></pre>
</li>
<li><p><strong>Null counts and positions</strong> - track where NULL values appear, allowing for more efficient processing</p>
</li>
<li><p><strong>Value distribution information</strong> - some formats store approximate histograms or count distinct estimates</p>
</li>
<li><p><strong>Row groups and column chunks</strong> - data is organized into row groups with separate column chunks, making it possible to read only relevant portions</p>
</li>
</ol>
<p>When scanning multiple Parquet files (a common Big Data scenario), these statistics become even more powerful. For example, when scanning a directory of parquet files such as the example below:</p>
<pre><code class="language-python">query = pl.scan_parquet("data/*.parquet").filter(pl.col("date") &gt; "2023-01-01")

</code></pre>
<p>Polars can use file-level statistics to skip entire files without opening them if they don't contain relevant data.</p>
<p>This metadata-driven optimization is crucial for performance when working with large datasets spread across many files. It is particularly impactful on cloud storage platforms such as <a href="https://aws.amazon.com/s3/">Amazon S3</a> or <a href="https://learn.microsoft.com/en-us/fabric/onelake/onelake-overview">Microsoft OneLake</a> which implement data lake or lakehouse architectures where data tends to be written in Parquet or Delta format. This is a key advantage of columnar formats that Polars fully leverages and we'll illustrate this in action in our <a href="https://endjin.com/blog/practical-polars-code-examples-everyday-data-tasks">next blog</a>.</p>
<h3 id="apache-arrow-memory-model">Apache Arrow Memory Model</h3>
<p>Polars implements columnar storage using the <a href="https://arrow.apache.org/">Apache Arrow</a> specification for in-memory analytical data storage, enabling seamless integration with <a href="https://arrow.apache.org/powered_by/">a growing ecosystem of popular data tools</a> which have adopted the same standard. This includes <a href="https://spark.apache.org/">Apache Spark</a>, <a href="https://pandas.pydata.org/docs/reference/arrays.html#pyarrow">pandas</a> and <a href="https://duckdb.org/docs/stable/guides/python/sql_on_arrow">DuckDB</a>.</p>
<p>Apache Arrow is particularly popular for its ability to handle large datasets efficiently and its support for zero-copy data sharing between different tools which may have been written in different languages - this eliminates the need for a serialization and deserialization overhead. This feature is crucial for applications that require low-latency data access and processing, such as machine learning pipelines, data streaming systems, high-performance computing and data engineering, where you are often integrating multiple tools to deliver the solution.</p>
<p>Polars implements its own query engine while adhering to the Arrow specification for memory layout. This foundation allows Polars to efficiently process data without the overhead of converting between different memory representations.</p>
<p>This approach delivers two key benefits:</p>
<ol>
<li><strong>Zero-copy data sharing</strong> between processes and tools that understand Arrow.</li>
<li><strong>Ecosystem compatibility</strong> with the growing universe of Arrow-enabled tools.</li>
</ol>
<p>For example, because it adopts Apache Arrow, Polars can efficiently read and write Parquet files, exchange data with DuckDB, and convert to and from Pandas DataFrames with minimal overhead. This inter-operability helps with migration as it allows incremental adoption of Polars alongside legacy technology. This is illustrated below:</p>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/01/apache-arrow-standard-enables-zero-copy-sharing-across-tools.png"><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/01/apache-arrow-standard-enables-zero-copy-sharing-across-tools.png" alt="Apache Arrow enables zero copy sharing of analytics data between tools." title="Apache Arrow enables zero copy sharing of analytics data between tools." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/01/apache-arrow-standard-enables-zero-copy-sharing-across-tools.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/01/apache-arrow-standard-enables-zero-copy-sharing-across-tools.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/01/apache-arrow-standard-enables-zero-copy-sharing-across-tools.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/01/apache-arrow-standard-enables-zero-copy-sharing-across-tools.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></a></p>
<h2 id="composable-expression-system">Composable Expression System</h2>
<p>Polars' expression system represents perhaps its most elegant innovation from a user perspective. Expressions in Polars are more readable and give the optimizer visibility of the logic you want to apply.</p>
<p>Vink is emphatic about the importance of expressions: "we see the requirement of a Lambda... as sort of a failure of our API."<a id="fnref:3" href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast#fn:1" class="footnote-ref"><sup>1</sup></a> This philosophy drives continuous improvement of the expression system to make it increasingly flexible.</p>
<p>Here's an example of such a Polars expression from <a href="https://endjin.com/blog/practical-polars-code-examples-everyday-data-tasks">Part 3</a> of this series. We'll provide more background there, but hopefully you will see similarities with the <a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/index.html">PySpark SQL and DataFrame API</a> and functional programming:</p>
<pre><code class="language-python">countries = (
    countries
    .filter(~pl.col("region").is_in(["Aggregates"]))
    .select(["country_code", "country_name", "region", "capital_city", "longitude", "latitude"])
    .sort(["country_name"])
)
</code></pre>
<p>This composable expression system is a domain specific language (DSL) which provides the foundation for further optimisations we set out below.</p>
<h2 id="lazy-evaluation-and-query-optimization">Lazy Evaluation and Query Optimization</h2>
<p>One of Polars' most distinctive features is its query optimizer, which draws directly from database technology. When using Polars' lazy execution mode the user is building an expression, operations aren't performed immediately but collected into a query plan. Before execution, Polars analyzes this plan and applies optimizations that can yield orders of magnitude performance improvements, and users get them automatically without changing their code.</p>
<p>While Pandas executes operations immediately, Polars can defer execution to build and optimize a complete query plan.</p>
<p>This is similar to how <a href="https://learn.microsoft.com/en-us/dotnet/csharp/advanced-topics/expression-trees/">LINQ Expression Trees work in .NET</a> and in <a href="https://reaqtive.net/">Reactive Queries in Rx &amp; Reaqtor</a> where the execution of the query is deferred until a result is needed, adopting the <a href="https://en.wikipedia.org/wiki/Futures_and_promises">futures and promises</a> design pattern.</p>
<h3 id="how-lazy-evaluation-works">How Lazy Evaluation Works</h3>
<p>When using Polars' lazy API, operations don't execute immediately but instead build a logical query plan:</p>
<p>By using <code>pl.scan_csv()</code> in place of <code>pl.read_csv()</code> in the code below, the data is not loaded or processed. Instead it returns a <code>polars.LazyFrame</code> object which allows Polars to build a query plan.</p>
<pre><code class="language-python">plan = (
    pl.scan_csv("large_file.csv")
    .filter(pl.col("value") &gt; 100)
    .group_by("category")
    .agg(pl.col("value").mean().alias("avg_value"))
)
</code></pre>
<p>It means that subsequent operations we may wish to add simply get added to the query plan, for example:</p>
<pre><code class="language-python">plan = (
    plan
    .filter(pl.col("category").is_in(["Category X", "Category Y", "Category Z"]))
    .sort("avg_value", descending=True)
)
</code></pre>
<p>Execution happens only when you call collect():</p>
<pre><code class="language-python">result = plan.collect()
</code></pre>
<p>By separating the stages of building, optimising and executing the plan, Polars can analyze the entire operation chain and apply optimizations.</p>
<h3 id="the-query-optimizer">The Query Optimizer</h3>
<p>Polars' optimizer applies various transformations to the logical plan:</p>
<ol>
<li><strong>Predicate pushdown</strong> - move filters earlier to reduce data volume</li>
<li><strong>Projection pushdown</strong> - only read necessary columns from source</li>
<li><strong>Join optimization</strong> - select efficient join strategies based on data properties</li>
<li><strong>Common subexpression elimination</strong> compute repeated expressions only once</li>
<li><strong>Function simplification</strong> - replace complex operations with simpler equivalents</li>
</ol>
<p>We show this in action in our <a href="https://endjin.com/blog/practical-polars-code-examples-everyday-data-tasks">next blog</a>.</p>
<p>In benchmark tests, these optimizations alone can provide 5-10x performance improvements over naively executed queries.</p>
<h3 id="beyond-basic-optimizations">Beyond Basic Optimizations</h3>
<p>Polars' optimizer goes beyond simple rule-based transformations to apply more sophisticated optimizations:</p>
<ol>
<li><strong>Query rewriting</strong> - replace operation sequences with more efficient alternatives</li>
<li><strong>Specialized algorithms</strong> - use purpose-built implementations for common patterns</li>
<li><strong>Meta-optimizations</strong> - decide whether certain optimizations are worthwhile based on data characteristics</li>
</ol>
<p>For example, if you write <code>df.sort().head(10)</code>, Polars might replace this with a top-k algorithm that's much more efficient than sorting the entire dataset.</p>
<h2 id="parallel-execution-using-all-your-cores">Parallel Execution: Using All Your Cores</h2>
<p>Polars automatically parallelizes operations across all available cores. This isn't something users need to configure or enable, it happens transparently. In an era of 8, 16, or even more CPU cores on standard laptops (I have 20 on my Microfot Surface Studio laptop! 💪😎), this automatic parallelization represents a massive performance advantage without requiring any special coding patterns.</p>
<p>Unlike Pandas, which primarily operates on a single CPU core, Polars automatically parallelizes operations across all available cores by leveraging its Rust foundations. This parallelization happens in two complementary ways:</p>
<h3 id="parallel-aware-query-nodes">Parallel-Aware Query Nodes</h3>
<p>Major operations like joins, group-bys, filters, and sorts know how to divide work across threads. Each node in the query plan can implement its own parallelization strategy based on the specific operation and data characteristics.</p>
<h3 id="expression-thread-pool">Expression Thread Pool</h3>
<p>For expression evaluation, Polars uses a work-stealing thread pool:</p>
<ol>
<li>Work is divided into manageable chunks</li>
<li>Chunks are distributed across a pool of worker threads</li>
<li>When a thread finishes its work, it "steals" pending work from other threads</li>
<li>This continues until all work is complete</li>
</ol>
<p>This approach maximizes CPU utilization while avoiding the overhead of excessive thread creation and context switching.</p>
<p>The beauty of Polars' parallelism is that it's completely transparent. You don't need to explicitly parallelize your code or manage threads yourself.</p>
<p>The command <a href="https://docs.pola.rs/api/python/stable/reference/api/polars.thread_pool_size.html"><code>pl.thread_pool_size()</code></a> will return the number of threads that Polars is using - it is set automatically by the Polars engine and will generally equal the number of cores on your CPU. This can also be overriden by setting the <code>POLARS_MAX_THREADS</code> environment variable before process start.</p>
<h2 id="vectorized-execution-batch-processing-for-performance">Vectorized Execution: Batch Processing for Performance</h2>
<p>Polars uses vectorized execution to process data efficiently by leveraging modern hardware and SIMD (see below). Rather than processing one value at a time (like traditional loops) or entire columns at once (which can exhaust memory), Polars processes data in optimally-sized batches.</p>
<h3 id="the-goldilocks-zone-vector-sizing">The Goldilocks Zone: Vector Sizing</h3>
<p>Polars processes data in vectors of 1024-2048 items. This size is carefully chosen to:</p>
<ol>
<li><strong>Fit in CPU L1 cache</strong> - typically 32-128KB per core on modern processors</li>
<li><strong>Amortize function call overhead</strong> - processing batches reduces per-item overhead</li>
<li><strong>Enable compiler optimizations</strong> - predictable sizes allow better code generation</li>
<li><strong>Balance memory pressure</strong> - not too large to cause cache misses, not too small to waste cycles</li>
</ol>
<p>This "Goldilocks" approach to batch sizing delivers significant performance benefits over both row-by-row and whole-column processing.</p>
<h3 id="simd-instructions-one-instruction-multiple-data">SIMD Instructions: One Instruction, Multiple Data</h3>
<p>Modern CPUs include special registers that can process multiple values with a single instruction  -  known as SIMD (Single Instruction, Multiple Data). The specific capabilities vary by hardware:</p>
<table>
<thead>
<tr>
<th>Instruction Set</th>
<th>Register Width</th>
<th>Values per Operation (32-bit)</th>
<th>Platform</th>
</tr>
</thead>
<tbody>
<tr>
<td>SSE2</td>
<td>128-bit</td>
<td>4</td>
<td>Intel/AMD (baseline since ~2001)</td>
</tr>
<tr>
<td>AVX2</td>
<td>256-bit</td>
<td>8</td>
<td>Intel/AMD (since ~2013)</td>
</tr>
<tr>
<td>AVX-512</td>
<td>512-bit</td>
<td>16</td>
<td>Intel Xeon, some consumer chips</td>
</tr>
<tr>
<td>NEON</td>
<td>128-bit</td>
<td>4</td>
<td>ARM (including Apple Silicon)</td>
</tr>
</tbody>
</table>
<p>Without SIMD:</p>
<pre><code>Instruction 1: a[0] + b[0] 
Instruction 2: a[1] + b[1] 
Instruction 3: a[2] + b[2] 
Instruction 4: a[3] + b[3]
</code></pre>
<p>With SIMD (one instruction does the work of four):</p>
<pre><code>Instruction 1: [a[0],a[1],a[2],a[3]] + [b[0],b[1],b[2],b[3]]
</code></pre>
<p>Historically, exploiting SIMD required writing platform-specific assembly or intrinsics. This presented a significant portability challenge. However, modern compilers (GCC, Clang, and the Rust compiler LLVM backend) can now auto-vectorize code written in a certain style: tight loops, minimal branching, and predictable memory access patterns.</p>
<p>Polars is written to exploit SIMD instructions, through a combination of compiler auto-vectorization and, where necessary, manual implementation. The details vary by operation, but the result is transparent to the user: significant performance gains on modern hardware without platform-specific configuration.</p>
<h2 id="memory-management-efficiency-from-the-ground-up">Memory Management: Efficiency from the Ground Up</h2>
<p>Polars' architecture includes careful attention to memory management:</p>
<h3 id="efficient-data-types-and-memory-layout">Efficient Data Types and Memory Layout</h3>
<p>Polars has adopted the Apache Arrow memory specification and therefore its data types are based on that specification. The advantages of this approach is that data in memory is optimized for both memory usage and processing speed:</p>
<ol>
<li><strong>Primitive types</strong> - stored as packed arrays of values, offered at different bit sizes: 8, 16, 32 and 64.</li>
<li><strong>Decimal</strong> - 128 bit type, can exactly represent 38 significant digits.</li>
<li><strong>String data</strong> - uses a string cache for repeated values</li>
<li><strong>Categorical</strong> - optimal for encoding string based categorical columns which have low cardinality</li>
<li><strong>Enum</strong> - similar to <code>Categorical</code> but the categories are fixed and must be defined prior to data being loaded</li>
<li><strong>Temporal types</strong> - represented as efficient integers internally, <code>Int32</code> for Date (days since Unix epoch) and <code>Int64</code> for Datetime (ns, us, or ms since Unix epoch), Duration and Time (ns since midnight).</li>
<li><strong>Nested</strong> - enable complex data structures to be modelled via <code>Array</code> (fixed length), <code>List</code> (any length) and <code>Struct</code> (key value pairs).</li>
<li><strong>Missing values</strong> - uses "validity bitmaps" rather than sentinel values</li>
</ol>
<p>Each of these choices reduces memory consumption compared to Pandas' approach.</p>
<h3 id="zero-copy-operations-where-possible">Zero-Copy Operations Where Possible</h3>
<p>Polars uses zero-copy operations whenever feasible:</p>
<p>Selecting columns is a zero-copy operation:</p>
<pre><code class="language-python">subset = df.select("a", "b", "c")  # No data is copied
</code></pre>
<p>Filtering can also be highly efficient:</p>
<pre><code class="language-python">filtered = df.filter(pl.col("a") &gt; 0)  # Minimal memory overhead
</code></pre>
<p>This approach minimizes memory usage and improves performance by avoiding unnecessary data copying.</p>
<h3 id="spill-to-disk-for-large-workloads">Spill to Disk for Large Workloads</h3>
<p>For operations that don't fit in memory, Polars can transparently spill to disk:</p>
<ol>
<li>Process data in manageable chunks</li>
<li>Write intermediate results to disk when memory pressure is high</li>
<li>Read back as needed for final results</li>
</ol>
<p>This capability allows Polars to handle datasets larger than RAM, particularly with its streaming engine. But it comes at a performance penalty due to file IO generally being an order of magnitude slower than RAM IO.</p>
<h2 id="putting-it-all-together-polars-architecture-overview">Putting it all together: Polars architecture overview</h2>
<p>At this stage we've covered all of the small incremental gains that Polars achieves, but it is also worth stressing that the architecture as a whole applies robust computer science:</p>
<p>The following diagram consolidates the end to end architecture we've described above:</p>
<pre class="mermaid">flowchart TD
    subgraph PythonInput["Python Layer"]
        A[/"LazyFrame with Polars Expressions&lt;br/&gt;(DSL)"/]
    end

    subgraph Rust["Rust"]

      subgraph IR["Polars: IR"]
          B["Query Plan&lt;br/&gt;(AST-like structure)"]
          C{{"Schema Validation&lt;br/&gt;• Data types at each node&lt;br/&gt;• Early error detection"}}
      end

      subgraph Optimiser["Polars: Optimiser"]
          D["Optimisation Passes&lt;br/&gt;• Projection pushdown&lt;br/&gt;• Predicate pushdown&lt;br/&gt;• Join optimisation&lt;br/&gt;• Common subexpression elimination&lt;br/&gt;• Function simplification"]
          E["Optimised Plan"]
      end

      subgraph Engine["Polars: Engine"]
          F{"Engine Selection"}
          G["In-Memory Engine"]
          H["Streaming Engine"]
          I["GPU Engine&lt;br/&gt;(NVIDIA RAPIDS)"]
          J["Parallel Execution&lt;br/&gt;• Multi-threaded (GIL released)&lt;br/&gt;• SIMD instructions&lt;br/&gt;• Work-stealing thread pool"]
      end

    end

    subgraph RAM["Output: RAM"]
        K[("Arrow Memory&lt;br/&gt;(zero-copy to Python)")]
    end

    subgraph File["Output: File"]
        L[("Sink to Storage&lt;br/&gt;(e.g. Parquet)")]
    end

    subgraph PythonOutput["Python Layer"]
        M[/"Polars DataFrame or Series object"/]
        ERR["❌ Error raised&lt;br/&gt;before execution"]
    end

    A --&gt;|"Python bindings&lt;br/&gt;(same process)"| B
    B --&gt; C
    C --&gt;|"Types valid"| D
    C -.-|"Types invalid"| ERR
    D --&gt; E
    E --&gt; F
    F --&gt; G
    F --&gt; H
    F --&gt; I
    G --&gt; J
    H --&gt; J
    I --&gt; J
    J --&gt; K
    J --&gt; L
    K --&gt; M
</pre>
<p>The Polars expressions you write in Python form a Domain Specific Language (DSL) that allows you to describe operations declaratively.</p>
<p>This DSL is translated into an Intermediate Representation (IR)  -  what Vink describes as similar to "an AST in Python"<a id="fnref:4" href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast#fn:1" class="footnote-ref"><sup>1</sup></a>. You can inspect this using <code>.explain()</code> or <code>.show_graph()</code> on a LazyFrame.</p>
<p>The IR captures not just the chain of operations, but also the schema at each node. As Vink explains: "Polars knows the schema on any point in the lazy frame  -  on any node you can ask what the schema is."<a id="fnref:8" href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast#fn:2" class="footnote-ref"><sup>2</sup></a> This enables Polars to detect type mismatches and other errors before execution begins.</p>
<p>The plan is then passed to the optimizer, which seeks opportunities to reduce computation and data volume  -  projection pushdown, predicate pushdown, common subexpression elimination, and more.</p>
<p>Finally, the optimised plan is passed to the appropriate execution engine. As Vink notes: "you can have different engines, different backends for different data sizes because you have the distinction between the front end and the back end"<a id="fnref:5" href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast#fn:1" class="footnote-ref"><sup>1</sup></a>  -  whether that's the in-memory engine, streaming engine, or GPU-accelerated execution via NVIDIA RAPIDS.</p>
<p>The engine executes and the result is either returned to Python via Arrow-formatted memory (enabling zero-copy access), or written directly to storage formats like Parquet without materialising the full result in memory.</p>
<h2 id="performance-in-practice-research-and-real-world-results">Performance in Practice: Research and Real-World Results</h2>
<p>While theoretical advantages are important, what matters is real-world performance. Independent research and testing consistently show significant performance advantages for Polars across a wide range of operations.</p>
<h3 id="academic-and-industry-research">Academic and Industry Research</h3>
<p>A <a href="https://dl.acm.org/doi/10.1145/3661167.3661203">2024 study by Felix Hänestredt et al. published in the Proceedings of the Evaluation and Assessment in Software Engineering (EASE) conference</a> compared the energy efficiency and performance of various Python data processing libraries. Their findings confirmed that Polars significantly outperforms Pandas:</p>
<ul>
<li>Polars consumed approximately <strong>8 times less energy</strong> than Pandas in synthetic data analysis tasks with large dataframes</li>
<li>For TPC-H benchmarks (an industry-standard decision support benchmark), Polars was <strong>~40% more efficient</strong> than Pandas for large dataframes</li>
</ul>
<p>More recently, <a href="https://www.linkedin.com/posts/mimounedjouallah_polars-onelake-polars-activity-7416081338258214912-pnec/">a benchmark test published on LinkedIn</a> by <a href="https://www.linkedin.com/in/mimounedjouallah/">Mimouned Jouallah</a>. The benchmark involved processing 150 GB of CSV files and writing them to the Fabric lakehouse in Delta format. The benchmark was run on the smallest size of Fabric notebook with only 2 cores and 16GB of RAM. The results placed Polars ahead of <a href="https://duckdb.org/docs/stable/">DuckDB</a> and <a href="https://clickhouse.com/chdb">CHdb</a>.</p>
<h2 id="real-world-adoption">Real-World Adoption</h2>
<p>Beyond benchmarks, real-world adoption provides evidence of Polars' performance advantages. Organizations that have switched from Pandas to Polars frequently report:</p>
<ul>
<li>Batch processing jobs completing in minutes instead of hours</li>
<li>Ability to process larger datasets without upgrading hardware</li>
<li>Reduced cloud computing costs (and energy consumption) for data processing pipelines</li>
<li>Faster inner dev loop for developers, reducing time to value</li>
</ul>
<p>The growing adoption of Polars in production environments across various industries provides perhaps the strongest evidence of its performance benefits.</p>
<blockquote>
<p>At endjin, <strong>our default choice for dataframe driven pipelines is Polars</strong>. We only revert to Apache Spark for datasets that are genuinely "Big Data" in scale. We find that Polars is optimal for at least 90% of use workloads.</p>
</blockquote>
<h2 id="is-there-a-commercial-case">Is there a commercial case?</h2>
<p>We have not yet quantified the benefits, therefore we can't give you a concrete RoI.</p>
<p>However, we can say the positive impact of migrating to Polars is immediately apparent. Moving from Spark to Polars in production will typically reduces runtime and therefore the ongoing cost of running pipelines. But perhaps what is more apparent is that Polars unlocks the ability to develop and test locally - developers can use their laptops rather than spinning up extra capacity for development, which can often incur significant costs on Big Data platforms such as Databricks, Synapse and Microsoft Fabric. Developers are also more productive - test suites run in seconds not minutes. For example, when we migrated one workload from Spark to Polars our test suite for a specific use case reduced from 60 minutes to 9 seconds. This allowed us to extend our test suite and run tests more frequently - the result was more confidence in releases, and reduced time to value.</p>
<h2 id="conclusion-performance-by-design">Conclusion: Performance by Design</h2>
<p>Polars' exceptional performance is the result of deliberate architectural choices and careful implementation, applying best practices in computer science and decades of database research. By combining a Rust foundation, columnar storage, query optimization, parallel execution, and vectorized processing and support for the latest hardware features (SIMD), Polars delivers dramatic performance improvements over traditional DataFrame libraries.</p>
<p>What makes Polars particularly remarkable is how it achieves this performance while maintaining an elegant, user-friendly API. The complexity of the underlying engine is hidden behind a clean interface that focuses on expressing <em>what</em> you want to do rather than <em>how</em> to do it efficiently.</p>
<p>For data practitioners working with datasets that fit on a single machine, Polars represents a significant advancement in processing capability. It brings many of the optimization techniques previously found only in sophisticated database systems directly to the Python ecosystem, packaged in a form that's accessible to data scientists and analysts. It's like giving a data engineer superpowers!</p>
<blockquote>
<p>Data practitioners who have traditionally worked with Apache Spark on platforms like Databricks, Azure Synapse Analytics, or Microsoft Fabric are discovering that workloads they've historically run on distributed Spark clusters can be handled efficiently by Polars on a single machine.</p>
</blockquote>
<p>The advantages of adopting Polars in place of the <a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/index.html">PySpark SQL and Dataframe API</a> are compelling: simpler architecture without cluster management overhead, faster iteration cycles during development, lower infrastructure costs, and the ability to run complex data processing pipelines on commodity hardware or even locally. While Spark remains essential for truly massive datasets that require distributed processing, Polars' combination of performance and simplicity makes it an excellent choice for the substantial portion of analytical workloads.</p>
<p>This is Part 2 of our Adventures in Polars series:</p>
<ul>
<li><strong>Part 1: <a href="https://endjin.com/blog/polars-faster-pipelines-simpler-infrastructure-happier-engineers">Why Polars Matters</a></strong>  -  The Decision Makers Guide for Polars.</li>
<li><strong>Part 3: <a href="https://endjin.com/blog/practical-polars-code-examples-everyday-data-tasks">Code Examples for Everyday Data Tasks</a></strong>  -  Hands-on examples showing Polars in action.</li>
<li><strong>Part 4: <a href="https://endjin.com/blog/polars-workloads-on-microsoft-fabric">Polars Workloads on Microsoft Fabric</a></strong>  -  Running Polars on Fabric with OneLake integration.</li>
</ul>
<p>In our <a href="https://endjin.com/blog/practical-polars-code-examples-everyday-data-tasks">next article in this series</a>, we'll show Polars in action.</p>
<hr>
<p><em>Have you experienced performance improvements with Polars in your projects? What operations have you found particularly faster? Share your experiences in the comments below!</em></p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Adventures in Polars</h3>
        <span class="series-toc__count">4 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/polars-faster-pipelines-simpler-infrastructure-happier-engineers" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Why Polars Matters</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">2.</span>
                <span class="series-toc__part-title">What Makes Polars So Scalable and Fast?</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/practical-polars-code-examples-everyday-data-tasks" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Code Examples</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/polars-workloads-on-microsoft-fabric" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Polars Workloads on Fabric</span>
                </a>
            </li>
    </ol>
</aside>

<div class="footnotes">
<hr>
<ol>
<li id="fn:1">
<p>quote from blog <a href="https://youtu.be/ubqF0yGyphU">"827: Polars: Past, Present and Future  -  with Polars Creator Ritchie Vink" published on the Super Data Science: ML &amp; AI Podcast with Jon Krohn.</a><a href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast#fnref:1" class="footnote-back-ref">↩</a><a href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast#fnref:2" class="footnote-back-ref">↩</a><a href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast#fnref:3" class="footnote-back-ref">↩</a><a href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast#fnref:4" class="footnote-back-ref">↩</a><a href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast#fnref:5" class="footnote-back-ref">↩</a></p>
</li>
<li id="fn:2">
<p>quote from talk <a href="https://youtu.be/UwRlFtSd_-8">"What Polars does for you" presented at Europython Conference in 2023</a>.<a href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast#fnref:6" class="footnote-back-ref">↩</a><a href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast#fnref:7" class="footnote-back-ref">↩</a><a href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast#fnref:8" class="footnote-back-ref">↩</a></p>
</li>
</ol>
</div>]]></content:encoded>
    </item>
    <item>
      <title>Polars: Faster Pipelines, Simpler Infrastructure, Happier Engineers</title>
      <description>We've migrated our own IP and several customers from Pandas and Spark to Polars. The benefits go beyond raw speed: faster test suites, lower platform costs, and an API developers actually enjoy using.</description>
      <link>https://endjin.com/blog/polars-faster-pipelines-simpler-infrastructure-happier-engineers</link>
      <guid isPermaLink="true">https://endjin.com/blog/polars-faster-pipelines-simpler-infrastructure-happier-engineers</guid>
      <pubDate>Thu, 29 Jan 2026 05:31:00 GMT</pubDate>
      <category>Polars</category>
      <category>DataFrame</category>
      <category>Python</category>
      <category>Data</category>
      <category>Analytics</category>
      <category>Performance</category>
      <category>Rust</category>
      <category>Data Processing</category>
      <category>Microsoft Fabric</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/01/why-polars-matters.png" />
      <dc:creator>Barry Smart</dc:creator>
      <content:encoded><![CDATA[<p>TLDR; Polars is a DataFrame library written in Rust with Python bindings. We've migrated our own IP and several of our customers from both Pandas and Spark to Polars, and the benefits extend beyond raw speed: faster test suites, lower platform costs, and an API that developers actually enjoy using. It's open source, has zero dependencies, and can be deployed on a broad range of infrastructure options. If you're still defaulting to Pandas or reaching for Spark when datasets grow, it's worth understanding what this new generation of tooling can offer.</p>
<h2 id="why-were-writing-this">Why We're Writing This</h2>
<p>Over the past eighteen months, we've been migrating our core data engineering IP - and helping a number of our customers do the same - from <a href="https://spark.apache.org/docs/latest/api/python/index.html">PySpark</a> and <a href="https://pandas.pydata.org/">Pandas</a> based solutions to <a href="https://pola.rs/">Polars</a>. The results have been compelling enough that we felt it was time to share what we've learned.</p>
<blockquote>
<p><strong>At endjin, Polars is our default choice for DataFrame-driven pipelines.</strong> We reach for <a href="https://spark.apache.org/">Apache Spark</a> on the few occasions when data volumes genuinely require distributed compute.</p>
</blockquote>
<p>This is a practitioner's perspective on a tool we've bet on, deployed to production, and would choose again.</p>
<h2 id="what-weve-seen-in-practice">What We've Seen in Practice</h2>
<p>The headline benefits are significant. Here's what changed when we migrated from PySpark to Polars:</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody>
<tr>
<td>Test suite execution</td>
<td>~60 minutes (Spark spin-up)</td>
<td>~30 seconds</td>
</tr>
<tr>
<td>Developer iteration cycle</td>
<td>Deploy to cluster, wait, check logs</td>
<td>Run locally, iterate, commit</td>
</tr>
<tr>
<td>Infrastructure model</td>
<td>Distributed compute on PaaS (e.g. Databricks)</td>
<td>Single-node commodity hardware</td>
</tr>
<tr>
<td>Monthly compute costs</td>
<td></td>
<td>&gt;50% reduction (one customer)</td>
</tr>
</tbody>
</table>
<p>Beyond the numbers, there's a qualitative shift that's harder to measure but equally important: developers spend less time fighting their tools and more time solving business problems.</p>
<p>The intuitive API and strict type system mean fewer runtime surprises. The lightweight footprint means local development works, giving developers access to modern <a href="https://en.wikipedia.org/wiki/Integrated_development_environment">IDE</a> tooling and coding agents: a complete contrast to remote Spark environments and the frustrations of working in a web browser based UI.  And because Polars is open source with zero dependencies, there's no vendor lock-in, no licensing complexity, and no heavyweight runtime to manage.</p>
<p>Independent research has measured the environmental impact too: Polars uses approximately <a href="https://dl.acm.org/doi/10.1145/3661167.3661203">8x less energy than equivalent Pandas operations</a> - something that matters increasingly to our clients with sustainability commitments.</p>
<p>It's time for organisations to seriously evaluate this new generation of data tooling. The technology has matured, the ecosystem is growing, and an increasing number of organisations are seeing order-of-magnitude gains from making the transition.</p>
<p>This blog aims to give you the information you need to determine whether deeper evaluation is merited for your organisation.</p>
<h2 id="what-is-polars">What is Polars?</h2>
<p>As Polars creator <a href="https://www.ritchievink.com/">Ritchie Vink</a> puts it:</p>
<p>"Polars is a query engine with a DataFrame front end... it respects decades of relational database research."<a id="fnref:1" href="https://endjin.com/blog/polars-faster-pipelines-simpler-infrastructure-happier-engineers#fn:1" class="footnote-ref"><sup>1</sup></a></p>
<p>This is a subtle but important distinction from describing it merely as a "DataFrame library", highlighting database-inspired optimization capabilities.  At its core, Polars is designed to provide:</p>
<ol>
<li><strong>Lightning-fast performance</strong> - 5-20x faster than Pandas for most operations, with some users reporting up to 100x speedups in specific scenarios</li>
<li><strong>Memory efficiency</strong> - dramatically reduced memory usage compared to Pandas</li>
<li><strong>An expressive, consistent API</strong> - a thoughtfully designed interface that balances power with readability</li>
<li><strong>Scalability on a single machine</strong> - making the most of modern hardware through parallelization and efficient algorithms</li>
</ol>
<h2 id="where-polars-fits">Where Polars Fits</h2>
<p>To understand where Polars adds value, it helps to see the landscape it sits within.</p>
<h3 id="the-two-migration-paths">The Two Migration Paths</h3>
<p>We see organisations coming to Polars from two directions:</p>
<p><strong>From Pandas:</strong> Teams hitting scaling limits. Datasets that used to fit comfortably in memory now cause out-of-memory errors. Operations that used to be fast enough now take minutes. The reflexive answer is "we need Spark" - but that's often overkill.</p>
<p><strong>From Spark:</strong> Teams realising they're over-engineered. They're paying for distributed compute to process datasets that would fit on a laptop or a single small commodity compute node. They're waiting for clusters to spin up to run tests. The infrastructure complexity is slowing them down rather than enabling them.</p>
<p>Polars sits in the middle: powerful enough to handle datasets that break Pandas, simple enough that you don't need a platform team to run it.</p>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/01/motivations-to-move-from-pandas-or-spark-to-polars.PNG"><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/01/motivations-to-move-from-pandas-or-spark-to-polars.PNG" alt="Motivations for moving from Pandas or Spark to Polars shows two pathways to Polars one from Pandas and the other from Spark" title="Motivations for moving from Pandas or Spark to Polars shows two pathways to Polars one from Pandas and the other from Spark" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/01/motivations-to-move-from-pandas-or-spark-to-polars.PNG 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/01/motivations-to-move-from-pandas-or-spark-to-polars.PNG 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/01/motivations-to-move-from-pandas-or-spark-to-polars.PNG 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/01/motivations-to-move-from-pandas-or-spark-to-polars.PNG 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></a></p>
<h3 id="the-data-singularity">The Data Singularity</h3>
<p>There's a broader trend at play here. Hannes Mühleisen, co-creator of <a href="https://duckdb.org/">DuckDB</a>, co-founder and CEO of <a href="https://duckdblabs.com/">DuckDB Labs</a> and <a href="https://hannes.muehleisen.org/">Professor of Data Engineering at the University of Nijmegen</a> describes what he calls the <a href="https://endjin.com/blog/duckdb-rise-of-in-process-analytics-understanding-data-singularity">"data singularity"</a>:</p>
<blockquote>
<p>We are approaching a point where the processing power of mainstream single-node machines, [including laptops], will surpass the requirements of the vast majority of analytical workloads.</p>
</blockquote>
<p>CPU core counts have increased dramatically. RAM is plentiful. NVMe storage offers throughput that would have seemed impossible a decade ago. But most data tools were designed before this shift, and they don't take advantage of it.</p>
<p>However, Polars does. It's designed from the ground up to exploit modern hardware: automatic parallelisation across all cores, efficient memory use, and algorithms optimised for contemporary CPU architectures.</p>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/03/duckdb-data-singularity.png"><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/03/duckdb-data-singularity.png" alt="Illustration of Data Singularity: showing the point where the compute power of commodity hardware increases to the point where it is capable of processing 99% of useful datasets" title="Illustration of Data Singularity: showing the point where the compute power of commodity hardware increases to the point where it is capable of processing 99% of useful datasets" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/03/duckdb-data-singularity.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/03/duckdb-data-singularity.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/03/duckdb-data-singularity.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/03/duckdb-data-singularity.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></a></p>
<p>This means datasets that "required" Spark five years ago can now run on a single node <strong>IF</strong> you adopt the right tooling - i.e. in process engines such as Polars or DuckDB.</p>
<h2 id="what-makes-this-possible">What Makes This Possible</h2>
<p>You don't need to understand the internals to benefit from Polars, but it helps to know why it's fast. Here's the short version:</p>
<ul>
<li><p><strong>Built for analytics</strong> - Polars is designed specifically for analytical workloads (aggregations, joins, transformations) rather than trying to be general-purpose. This focus drives design decisions throughout.</p>
</li>
<li><p><strong>Automatic optimisation</strong> - when you write a Polars query, you're describing <em>what</em> you want, not <em>how</em> to compute it. Polars analyses your query and figures out the most efficient execution plan - reordering operations, eliminating redundant work, pushing filters down. You get database-grade query optimisation without writing SQL.</p>
</li>
<li><p><strong>Parallelism by default</strong> - Polars automatically uses all available CPU cores. No configuration, no special coding patterns. On a 16-core laptop, that's a potential 16x speedup over single-threaded tools - and you get it for free.</p>
</li>
<li><p><strong>Rust foundation</strong> - written in Rust (a systems programming language with C++-level performance), Polars has full control over memory layout and execution. There's no Python interpreter overhead in the hot path.</p>
</li>
</ul>
<p>The practical upshot: you write readable, declarative code, and Polars makes it fast. As Polars creator Ritchie Vink puts it: "Write readable idiomatic queries which explain your intent, and we will figure out how to make it fast."</p>
<p>We explore the technical details in <a href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast">Part 2: What Makes Polars So Scalable and Fast?</a> for those who want to go deeper.</p>
<h2 id="common-questions-we-get">Common Questions We Get</h2>
<p>When we recommend Polars to clients, certain questions come up repeatedly. Here's how we answer them:</p>
<h3 id="isnt-pandas-2.0-with-pyarrow-good-enough-now">"Isn't Pandas 2.0 with PyArrow good enough now?"</h3>
<p>Pandas has adopted Arrow for storage, which is a step forward. But the execution model is unchanged - Pandas still processes data single-threaded without query optimisation.</p>
<p>As Ritchie Vink has noted: "Pandas is using PyArrow kernels for compute... those are totally different implementations."<a id="fnref:2" href="https://endjin.com/blog/polars-faster-pipelines-simpler-infrastructure-happier-engineers#fn:2" class="footnote-ref"><sup>2</sup></a> Adopting Arrow for storage doesn't give you Polars' query optimizer or automatic parallelisation.</p>
<p>Our benchmarks show Polars still outperforming Pandas 2.x by 5-10x on typical analytical workloads - and often more on complex queries where optimisation matters most.</p>
<p>Furthermore, because Pandas operates only in "eager" execution mode, we find we tend to hit out of memory limitations, switching to Polars and leveraging its "lazy" model of execution will often overcome that limitation allowing you to get more mileage out of existing infrastructure.</p>
<h3 id="why-not-just-optimise-our-spark-jobs">"Why not just optimise our Spark jobs?"</h3>
<p>You can, and sometimes you should. But ask yourself: do you actually <em>need</em> distributed compute?</p>
<p>We've seen teams running Spark clusters to process datasets that fit comfortably in memory on a single node. The overhead of cluster management, network serialisation, and distributed coordination often exceeds the benefit.</p>
<p>If your data fits on one machine, Polars will almost certainly be faster <em>and</em> simpler. If it doesn't, Spark (or Polars Cloud) makes sense. The key is being honest about which category you're in - and many teams overestimate.</p>
<h3 id="whats-the-migration-risk">"What's the migration risk?"</h3>
<p>Lower than you might expect, with caveats.</p>
<p>The main prerequisite is good test coverage. If you have comprehensive tests for your data pipelines, migration becomes a matter of swapping out the implementation and verifying the outputs match.</p>
<p>When migrating from Pandas, we have found that Polars' stricter type system often <em>catches</em> bugs that Pandas was silently propagating.  The APIs share conceptual similarities, especially if you're already using method chaining in Pandas. The mental model shift is less about syntax and more about embracing lazy evaluation and expressions over imperative loops and <code>.apply()</code>.</p>
<p>Migration from PySpark DataFrame API is more straightforward.  The APIs are more similar.  PySpark also uses lazy evaluation.</p>
<p>We've migrated multiple production systems successfully. The pattern that works: migrate incrementally, run both implementations in parallel initially, validate outputs match, then cut over.</p>
<h3 id="is-this-mature-enough-for-production">"Is this mature enough for production?"</h3>
<p>Yes. Polars has been in production use since 2021. There's now a company (<a href="https://pola.rs/">Polars Inc.</a>) providing commercial support and building enterprise features. The ecosystem has reached critical mass.</p>
<p>Perhaps more tellingly, Microsoft has included Polars in the default build for <a href="https://learn.microsoft.com/en-us/fabric/data-engineering/using-python-experience-on-notebook">Fabric Python Notebooks</a> - they're not betting on immature technology.</p>
<h3 id="what-if-we-need-to-scale-beyond-a-single-machine">"What if we need to scale beyond a single machine?"</h3>
<p>Polars has a streaming engine for larger-than-RAM datasets on a single node. For true horizontal scaling, Polars Inc. offers <a href="https://cloud.pola.rs/">Polars Cloud</a>, which distributes queries across multiple machines while maintaining the same API.</p>
<p>And because Polars is built on Apache Arrow, it interoperates cleanly with Spark if you need to hand off to distributed compute for specific workloads.</p>
<h3 id="can-we-use-ai-agents-to-do-the-migration">"Can we use AI agents to do the migration?"</h3>
<p>Yes, with appropriate guardrails.  AI agents such as <a href="https://github.com/features/copilot">GitHub Copilot</a> or <a href="https://claude.com/product/claude-code">Claude Code</a> could be used for the heavy lifting.</p>
<p>An AI assisted migration will be more effective if your legacy code base is well structured and documented.  You will also likely need to document or provide examples of patterns that you want the AI agent to apply in the migrated code base.  A suite of tests and "human in the loop" code reviews are essential.</p>
<p>We recommend doing the initial proof of value manually to understand the nuance and then capture learnings from that to be carried forward by the AI agent.  For example, an AI is likely to carry forward the use of imperative loops and <code>.apply()</code> in Pandas into a Polars version of the same code, which is sub-optimal.  By adding coding standards and examples into the context for the AI agent, you will encourage it to use Polar's rich expression language and therefore take full advantage of its Rust based engine.</p>
<h2 id="our-stack">Our Stack</h2>
<p>Polars doesn't exist in isolation. A key part of our evaluation was whether it integrates with the tools our clients already use.</p>
<p>Here's what we've found works well in practice:</p>
<h3 id="visualization">Visualization</h3>
<ul>
<li><strong><a href="https://plotly.com/python/">Plotly</a></strong> - Plotly works cleanly with Polars. While Python charting libraries often expect Pandas DataFrames, Polars' zero-copy conversion to arrow makes passing data to plotting libraries efficient.</li>
<li><strong><a href="https://altair-viz.github.io/">Altair</a></strong> - a declarative statistical visualization library that has excellent native support for Polars.</li>
</ul>
<h3 id="data-validation">Data Validation</h3>
<ul>
<li><strong><a href="https://pandera.readthedocs.io/en/stable/">Pandera</a></strong> - a statistical data validation toolkit. Pandera allows you to define DataFrame schemas (including checks for data types and value ranges) and validate your Polars DataFrames at runtime to ensure data quality.</li>
</ul>
<h3 id="interoperability">Interoperability</h3>
<ul>
<li><strong><a href="https://arrow.apache.org/">Apache Arrow</a></strong> -a Polars is built on top of the Arrow specification. This allows for <strong>Zero-Copy</strong> data exchange with other Arrow-based tools. You can pass a Polars DataFrame to <code>pyarrow</code> or other Arrow-consumers without duplicating the data in memory.</li>
<li><strong><a href="https://duckdb.org/">DuckDB</a> + Polars</strong> - we often use these together. DuckDB can query Polars DataFrames directly via SQL without copying data. This lets us mix SQL (for complex window functions or ad-hoc exploration) with Polars expressions (for transformation pipelines) in the same workflow. See our <a href="https://endjin.com/blog/duckdb-rise-of-in-process-analytics-understanding-data-singularity">DuckDB blog series</a> for more details.</li>
<li><strong><a href="https://narwhals-dev.github.io/narwhals/">Narwhals</a></strong> - a DataFrame-agnostic API that lets library maintainers write code once and support Polars, Pandas, and other DataFrame libraries automatically. Pass in a Polars DataFrame, get a Polars DataFrame back. This is how libraries like Altair added Polars support without taking on Polars as a dependency—keeping them lightweight and broadly compatible.</li>
</ul>
<h3 id="machine-learning">Machine Learning</h3>
<ul>
<li><strong><a href="https://scikit-learn.org/">scikit-learn</a></strong> - Polars integrates well with the standard ML stack. You can pass Polars DataFrames directly to many scikit-learn models, or efficiently convert them to the required format for training.</li>
</ul>
<h3 id="web-applications">Web Applications</h3>
<ul>
<li><strong><a href="https://streamlit.io/">Streamlit</a></strong> - popular framework for building interactive data apps. Streamlit has added native support for Polars, meaning you can pass <code>pl.DataFrame</code> objects directly to functions like <code>st.dataframe()</code> and <code>st.line_chart()</code> without manual conversion.  We love Streamlit and have a series of videos on this topic such as: <a href="https://youtu.be/1nZhPP8G1cY">Getting Started with Python &amp; Streamlit</a></li>
</ul>
<h3 id="the-pandas-escape-hatch">The "Pandas Escape Hatch"</h3>
<ul>
<li><strong><a href="https://pandas.pydata.org/">Pandas</a></strong> - the reality is that the Python ecosystem is vast, and you may find libraries that strictly require Pandas input. You can bridge this gap using <code>.to_pandas()</code>.</li>
</ul>
<div class="aside"><p><strong>Health Warning ⚠️</strong>: this converts your data into the Pandas format. This is an expensive operation that copies data in memory and forces eager execution. Doing this routinely undermines the performance and memory-efficiency benefits you chose Polars for in the first place! Use it only when strictly necessary.</p>
</div>
<h3 id="cloud-platform">Cloud platform</h3>
<ul>
<li><strong><a href="https://cloud.pola.rs/">Polars Cloud</a></strong> - the commercial offering from Polars Inc. extends the open-source engine with serverless execution, horizontal scaling for partitioned data, and fault tolerance. Polars Cloud lets you take local Polars queries and run them at scale without managing infrastructure—using the same open-source engine under the hood.</li>
<li><strong><a href="https://learn.microsoft.com/en-us/fabric/">Microsoft Fabric</a></strong> - Polars is part of the default build for <a href="https://learn.microsoft.com/en-us/fabric/data-engineering/using-python-experience-on-notebook">Fabric <strong>Python</strong> Notebooks</a>, providing an out-of-the-box experience for running fast analytics on the platform.  With the flexibility to revert to Spark (which is available on <a href="https://learn.microsoft.com/en-us/fabric/data-engineering/author-execute-notebook">Fabric <strong>PySpark</strong> Notebook</a>) for the larger workloads.</li>
<li><strong><a href="https://learn.microsoft.com/en-us/azure/container-apps/overview">Azure Container Apps</a></strong> - enable containerised deployment of Polars workloads onto Azure which can be triggered by Data Factory, Synapse or Fabric pipelines connecting to ADLS2 or OneLake storage.</li>
</ul>
<h3 id="development-environment">Development environment</h3>
<ul>
<li><strong><a href="https://code.visualstudio.com/">Visual Studio Code</a></strong> - we love VS Code!  Polars is very suited to local development and works well with features such as <a href="https://code.visualstudio.com/docs/devcontainers/containers">dev containers</a> and <a href="https://code.visualstudio.com/docs/datascience/data-wrangler">data wrangler</a> to create a developer experience that feels like mainstream software engineering.</li>
</ul>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/01/the-extensive-python-polars-ecosystem.PNG"><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2026/01/the-extensive-python-polars-ecosystem.PNG" alt="The extensive Polars ecosystem illustrated using logos across different categories including visualization, data validation, interoperability, machine learning, web applications and cloud platform" title="The extensive Polars ecosystem illustrated using logos across different categories including visualization, data validation, interoperability, machine learning, web applications and cloud platform" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2026/01/the-extensive-python-polars-ecosystem.PNG 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2026/01/the-extensive-python-polars-ecosystem.PNG 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2026/01/the-extensive-python-polars-ecosystem.PNG 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2026/01/the-extensive-python-polars-ecosystem.PNG 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></a></p>
<h2 id="getting-started-with-polars">Getting Started with Polars</h2>
<p>Ready to try Polars? The <a href="https://docs.pola.rs/">official documentation</a> provides comprehensive guides, examples and API definition.</p>
<h2 id="the-road-ahead">The Road Ahead</h2>
<p>Polars is actively developed, with a clear trajectory:</p>
<p><strong>Streaming Engine:</strong> Now available, this handles datasets larger than RAM by processing in batches and spilling to disk when needed - extending Polars' reach without requiring distributed compute.</p>
<p><strong>Polars Cloud:</strong> The commercial offering from Polars Inc. adds horizontal scaling, fault tolerance, and serverless execution while maintaining the same API. Write queries locally, run them at scale.</p>
<p><strong>GPU Support:</strong> Integration with Nvidia RAPIDS for GPU-accelerated processing on supported operations.</p>
<p><strong>Growing Ecosystem:</strong> The plugin system allows custom extensions, and the community continues to build integrations with specialised tools.</p>
<p>We get a sense of an organically growing ecosystem with solid foundations - one that can scale across multiple dimensions (scale up, scale out, batch and streaming) as needs evolve.</p>
<h2 id="our-position">Our Position</h2>
<p>After eighteen months and multiple production migrations, Polars is our default choice for DataFrame-driven pipelines. We revert to Spark only when data volumes genuinely require distributed compute, which in our experience, is for less than 5% of use cases.</p>
<p>The technology has matured. The ecosystem is production-ready. The benefits compound over time as your team builds fluency with the expression API and lazy evaluation model.</p>
<p>Should you evaluate Polars? Consider your situation:</p>
<p><strong>Strong fit:</strong></p>
<ul>
<li>You're hitting performance or scaling limits with Pandas</li>
<li>You're using Spark but suspect it's overkill for your data volumes</li>
<li>You value developer experience and fast iteration cycles</li>
<li>You want to reduce infrastructure costs and complexity</li>
<li>You're building new pipelines and want to start with modern tooling</li>
</ul>
<p><strong>Weaker fit:</strong></p>
<ul>
<li>Your existing pipelines are genuinely fast enough</li>
<li>You lack test coverage to validate migration correctness</li>
<li>You have deep investment in Spark-specific features (MLlib, GraphX, etc.)</li>
<li>Your data volumes genuinely require distributed compute</li>
</ul>
<p>For most data teams, we believe Polars represents a significant opportunity to simplify infrastructure, accelerate development, and reduce costs - without sacrificing capability.</p>
<p>If you're still defaulting to Pandas out of habit, or spinning up Spark clusters for datasets that fit in memory, it's time to reconsider.</p>
<hr>
<h2 id="whats-next">What's Next</h2>
<p>This is Part 1 of our Adventures in Polars series:</p>
<ul>
<li><strong>Part 2: <a href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast">What Makes Polars So Scalable and Fast?</a></strong> - The technical deep-dive: lazy evaluation, query optimisation, parallelism, and the Rust foundation.</li>
<li><strong>Part 3: <a href="https://endjin.com/blog/practical-polars-code-examples-everyday-data-tasks">Code Examples for Everyday Data Tasks</a></strong> - Hands-on examples showing Polars in action.</li>
<li><strong>Part 4: <a href="https://endjin.com/blog/polars-workloads-on-microsoft-fabric">Polars Workloads on Microsoft Fabric</a></strong> - Running Polars on Fabric with OneLake integration.</li>
</ul>
<hr>
<p><em>What's your experience with Polars? Are you evaluating it, migrating to it, or already using it in production? We'd be interested to hear - share your thoughts in the comments below.</em></p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Adventures in Polars</h3>
        <span class="series-toc__count">4 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">1.</span>
                <span class="series-toc__part-title">Why Polars Matters</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/under-the-hood-what-makes-polars-so-scalable-and-fast" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">What Makes Polars So Scalable and Fast?</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/practical-polars-code-examples-everyday-data-tasks" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Code Examples</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/polars-workloads-on-microsoft-fabric" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">Polars Workloads on Fabric</span>
                </a>
            </li>
    </ol>
</aside>

<div class="footnotes">
<hr>
<ol>
<li id="fn:1">
<p>quote from talk <a href="https://youtu.be/UwRlFtSd_-8">"What Polars does for you" presented at Europython Conference in 2023</a>.<a href="https://endjin.com/blog/polars-faster-pipelines-simpler-infrastructure-happier-engineers#fnref:1" class="footnote-back-ref">↩</a></p>
</li>
<li id="fn:2">
<p>quote from blog <a href="https://youtu.be/ubqF0yGyphU">"827: Polars: Past, Present and Future - with Polars Creator Ritchie Vink" published on the Super Data Science: ML &amp; AI Podcast with Jon Krohn.</a><a href="https://endjin.com/blog/polars-faster-pipelines-simpler-infrastructure-happier-engineers#fnref:2" class="footnote-back-ref">↩</a></p>
</li>
</ol>
</div>]]></content:encoded>
    </item>
    <item>
      <title>Reactive Extensions for .NET Status and Plans for .NET 10</title>
      <description>&lt;p&gt;&lt;a href="https://endjin.com/who-we-are/our-people/ian-griffiths/"&gt;Ian Griffiths&lt;/a&gt;, Technical Fellow at endjin, shares the latest updates on the &lt;a href="https://github.com/dotnet/reactive"&gt;Reactive Extensions for .NET&lt;/a&gt; (AKA ReactiveX AKA Rx.NET). Learn about the new features in Rx 6.1, what .NET 10 means for the project, and the significant packaging changes coming in Rx v7.0 that finally solve the long-standing deployment bloat issue.&lt;/p&gt;
&lt;p&gt;In this talk:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Rx 6.1 New Features — DisposeWith operator for fluent CompositeDisposable usage, new TakeUntil overload with cancellation token support, and ResetExceptionDispatchState operator&lt;/li&gt;
&lt;li&gt;The Bloat Problem Explained — Why self-contained Windows deployments were pulling in 90MB of unnecessary WPF and Windows Forms assemblies&lt;/li&gt;
&lt;li&gt;Rx 7 Preview — How the new packaging model fixes bloat while maintaining source and binary compatibility&lt;/li&gt;
&lt;li&gt;Community Contributions — Features from Chris Pullman (ReactiveUI), Neils Berger, Daniel Weber, and Adam Jones&lt;/li&gt;
&lt;li&gt;Async Rx .NET — Status update and plans for a non-alpha release&lt;/li&gt;
&lt;/ul&gt;</description>
      <link>https://endjin.com/what-we-think/talks/reactive-extensions-for-dotnet-status-and-plans-for-dotnet-10</link>
      <guid isPermaLink="true">https://endjin.com/what-we-think/talks/reactive-extensions-for-dotnet-status-and-plans-for-dotnet-10</guid>
      <pubDate>Wed, 24 Dec 2025 18:30:00 GMT</pubDate>
      <category>Rx.NET</category>
      <category>ReactiveX</category>
      <category>Reactive Extensions for .NET</category>
      <category>Reactive Programming</category>
      <category>dotnet</category>
      <category>dotnetconf</category>
      <category>Open Source</category>
      <category>.NET Foundation</category>
      <category>.NET 10</category>
      <category>Talk</category>
      <enclosure length="0" type="image/jpeg" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/dotnetconf-2025-rx-dotnet-status-and-plans.jpg" />
      <dc:creator>Ian Griffiths</dc:creator>
      <content:encoded><![CDATA[<p><a href="https://endjin.com/who-we-are/our-people/ian-griffiths/">Ian Griffiths</a>, Technical Fellow at endjin, shares the latest updates on the <a href="https://github.com/dotnet/reactive">Reactive Extensions for .NET</a> (AKA ReactiveX AKA Rx.NET). Learn about the new features in Rx 6.1, what .NET 10 means for the project, and the significant packaging changes coming in Rx v7.0 that finally solve the long-standing deployment bloat issue.</p>
<p>In this talk:</p>
<ul>
<li>Rx 6.1 New Features — DisposeWith operator for fluent CompositeDisposable usage, new TakeUntil overload with cancellation token support, and ResetExceptionDispatchState operator</li>
<li>The Bloat Problem Explained — Why self-contained Windows deployments were pulling in 90MB of unnecessary WPF and Windows Forms assemblies</li>
<li>Rx 7 Preview — How the new packaging model fixes bloat while maintaining source and binary compatibility</li>
<li>Community Contributions — Features from Chris Pullman (ReactiveUI), Neils Berger, Daniel Weber, and Adam Jones</li>
<li>Async Rx .NET — Status update and plans for a non-alpha release</li>
</ul>
<p><a href="https://www.youtube.com/watch?v=y7Ks_bwSHUg"><img src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/dotnetconf-2025-rx-dotnet-status-and-plans.jpg"></a></p><p>Hi! Thanks for listening to this talk about what's been happening lately with the Reactive Extensions for .NET and what we've got planned. My name's Ian Griffiths. I'm a technical fellow at endjin, and my employer endjin maintains the Reactive Extensions for .NET. You can find the source code in this repo on GitHub.</p>
<p>If you're listening to this talk, it's likely that you already know about the Reactive Extensions, or as we usually call them, just Rx. But just in case you don't, here's a very quick introduction. Rx is an event-driven programming model. It's useful in any application where things happen. It provides a functional declarative programming style for writing code that responds to events.</p>
<p>This model has become popular in other programming languages, especially JavaScript, but it was originally a .NET technology invented by Microsoft. Rx .NET was one of the first projects that Microsoft open sourced, one of the first to come under .NET Foundation ownership, and it's been a community-supported project for over a decade now.</p>
<p>Today I'm gonna talk about things that have been, things that are, and some things that have not yet come to pass. More specifically, I'll talk about Rx 6.1, our most recent release. I'll also talk about what .NET 10 means for Rx and also for the related Ix project that lives in the same repository.</p>
<p>There's a new feature in the .NET 10 runtime class libraries that has a significant impact on us. And finally, I'll talk about our progress towards the next release, Rx version 7.0. To put this all in context, it's useful to know the recent history of Rx. endjin took over maintenance at the start of 2023.</p>
<p>This was a little over two years since there had last been a version since Rx five. The project had ground to a halt because its previous maintainers were no longer able to devote much time to it. Our first job was to bring the codebase back into line with current tooling, 'cause it had fallen behind and wasn't actually able to build on the current version of Visual Studio.</p>
<p>So we addressed that and then went on to add tests for newer versions of .NET, and then produced a new release, Rx version six. We spent a big chunk of the next year bringing the documentation up to date in the form of the free online ebook, Intro to Rx. We also spent a lot of time working out how best to solve a problem that I'll be talking about later, because that work will not ship until Rx 7.0.</p>
<p>So there wasn't a lot of visible progress. We had a couple of minor bug fix releases, but it wasn't until last month that we produced a release with any new features. And that's what I want to talk about first: Rx version 6.1, which shipped in October 2025. We've bumped the minor version number because there are three new features in Rx 6.1.</p>
<p>All of these arose from community input. Two were written by community members, and the third was based on community suggestions. So we have a new DisposeWith operator, which enables a fluent programming style when working with the CompositeDisposable type. This comes from Chris Pullman, a major contributor to the ReactiveUI project, and this extension fits in well with common coding idioms in that world.</p>
<p>To show how this works, I've got a program here based on one of the ReactiveUI examples. It's a very simple front end, and I can type search terms in here and it finds packages for me. This happens to be a WPF app, and as you can see, the main window here has this text box for me to type into and a list box that shows the result.</p>
<p>The basic idea with ReactiveUI is that we can represent user input as Rx observable streams, and we can also direct the output of observable streams into user interface elements. This code here is the basic logic for handling search input, and it's mostly standard Rx operators. This first method is from ReactiveUI and it lets me get an observable stream representing input to my textbox.</p>
<p>We're then using Rx's Throttle to ensure we don't perform searches too often while the user types, and this Where clause filters out empty inputs. And ultimately this delivers results into another property. And so that's one of the basic ideas of the ReactiveUI library. It uses Rx to define how information flows through the application.</p>
<p>Now if we look at the actual main window, this is where the code that you just saw gets hooked up to the user interface elements. You can see that this connects the application logic's search term input to the actual text box, and this connects the search logic's output to an actual list box. And this code here is where the new feature that we've added in Rx 6.1 comes in useful.</p>
<p>UI elements get created and torn down all the time. Entries in the search results, for example, appear when I type things and get replaced when I type something else. So each time some new UI opens up, not only do we need to run this sort of code to connect everything up, we also need to be ready to shut it all down cleanly. To enable that, this WhenActivated method passes me this argument, which uses Rx's CompositeDisposable type. Now that's been in Rx for years and it's just a collection of IDisposable objects, the idea being that they can all be disposed at once when needed. Any disposable objects I put in here will automatically be disposed when this user interface element goes away.</p>
<p>Right now I'm just calling Add to add things to that CompositeDisposable, and that's okay, but it's not quite the normal style for a ReactiveUI app. Normally we chain method calls together one after another in what's often called the fluent style. And if I show you another UI element, the one for an individual search result, you can see it's using that CompositeDisposable slightly differently.</p>
<p>Instead of wrapping each of these setup lines in a call to Add, I've got just one more fluent invocation on the end of it with this DisposeWith method. It's a small change, but it enables teardown to be handled slightly more neatly. And that's Rx 6.1's new DisposeWith feature. We also have a new overload of the TakeUntil operator.</p>
<p>This comes from Neils Berger and incorporates feedback from Daniel Weber, both members of the Rx community. Existing overloads enable a sequence to be observed until either an element matches some criteria or some other observable source completes. But this now enables a cancellation token to signal the instant at which the sequence should complete.</p>
<p>Finally, we have a new operator called ResetExceptionDispatchState, developed in response to feedback from Adam Jones, and this one is best shown by example. Rx has always offered this Throw operator. When you subscribe to the observable it returns, it immediately calls OnError, passing this exception. Since we construct just a single exception, it will reuse it for each subscription.</p>
<p>Normally that's not a problem, but there's one situation in which this can produce surprising behavior. But before I show you that situation, I want to demonstrate something about exceptions that has nothing to do with Rx and which you might not be aware of. I've got a different program here that creates a single exception object, and then each time around this loop, we wrap that in a task and await the task. That await will, of course, throw the exception, and this C# catch block just displays it. But watch what happens when I run this. Each time around the loop, my stack trace gets longer and longer. This is a feature of how the .NET runtime throws asynchronous exceptions. Each throw appends the current location to the stack trace, and normally that's what we want.</p>
<p>If an exception has traveled through multiple await statements before reaching a catch block, we want the stack trace to reflect that whole history, which is why the asynchronous rethrow appends new information to the existing stack trace. But this mechanism assumes that when the error occurred, something did actually use a normal throw operation.</p>
<p>And finally, there are a few changes that are minor, but which are technically breaking changes that have been waiting for the next major release. So we made a small change to nullability handling of the OfType operator to align it with some other LINQ implementations. And there are some behaviors that we consider to be unintended and where we think the fix will align with how people expect things to work, but technically it's a breaking change. And finally, we're gonna stop producing new versions of the old compatibility facades that were introduced in Rx version four.</p>
<p>But the big one is the fix for the bloat issues. We are going to introduce a significant change in Rx .NET packaging. It will only affect people building UI applications on Windows, though. I've got a console application with a Windows-specific target framework. It needs that because it invokes certain Windows APIs. The Program.cs here, I'm using the network availability API provided by WinRT, and I've wrapped it using Rx to provide a stream of notifications when the computer loses or acquires network connections. That's a Windows-specific API, but this is just a console app.</p>
<p>So this illustrates that just because you specified a Windows target framework, it doesn't necessarily mean you are building a classic desktop application with a user interface. But look at this build output folder. Now, I've configured this project to use self-contained deployment, meaning that it brings its own copy of the .NET runtime and any other components that it needs to run.</p>
<p>This application can be installed by simply copying this entire folder to the target machine. It does not need the .NET runtime to be pre-installed, but it's huge. Now part of that is simply that the .NET runtime is quite large, but that's not the whole story. This is much bigger than a self-contained console app would normally be.</p>
<p>Looking more closely, we see these PresentationFramework components. These are part of WPF, one of the Windows desktop frameworks that .NET offers. And a little further down, we can see that the other one, Windows Forms, is here too. These two UI frameworks are adding about 90 megabytes to the size of this folder.</p>
<p>We can mitigate this a little by enabling trimming. That will make the whole folder a great deal smaller, but even so, it ends up being many megabytes larger than it would've been if these UI frameworks weren't here. So why are they here? It's because of the unfortunate consequences of a design decision made back in Rx version four.</p>
<p>This decision is known as "the great unification," and it took us through a world in which Rx consisted of multiple components in which it was kind of unclear what they all did, and even more unclear which ones were required in which situations, and it took us into a world where there's exactly one component: System.Reactive. And at the time, this was great, but the decision to include UI framework support as part of this great unification has turned out to be problematic. The effect is that if you build an application with a target in which Windows Forms and WPF are available, Rx will provide its Windows Forms and WPF support, and that's a problem because to do that, it adds an implicit reference to the desktop UI framework, meaning that self-contained deployments get a copy of these UI frameworks even if the application itself isn't actually using them.</p>
<p>I'm now gonna upgrade this project to a preview build of Rx version 7.0 and rebuild it. Now the project appears to work exactly as before, and our goal is that for anyone who wasn't building WPF, Windows Forms, or UWP applications, that it all carries on working exactly as before.</p>
<p>So if you weren't running into this bloat issue, or perhaps because you weren't using self-contained deployment, then this shouldn't really affect you. But this example does use it, so let's look at the output folder. This is now much smaller. This is almost exactly the same size as a simple Hello World application, made slightly larger just by the presence of the System.Reactive assembly, but those PresentationFramework and Windows Forms assemblies are gone.</p>
<p>So you can see that the main effect has been achieved, but this has consequences for applications that actually are using the UI framework features. So here I've got a WPF project that is using Rx six, and this line of code here is using a WPF-specific feature. Specifically, this ensures that any events that emerge from this observable are delivered via the correct thread for this window.</p>
<p>I'm now gonna try upgrading this to Rx 7.0.</p>
<p>Now I've got compiler errors. To enable Windows projects to use Rx without being forced to have a dependency on the desktop UI frameworks, we've had to hide the relevant types. They are still actually present at runtime to ensure binary compatibility, but they are effectively invisible at build time. I could spend well over an hour explaining why this was the least bad available solution to the problem, but we don't have time for that in this particular talk. Anyway, notice that in addition to the error, we've got this diagnostic. Now we've added a code analyzer to Rx 7.0 that detects this exact situation and tells you what you need to do to fix it.</p>
<p>We've done this because we know people will get this error when they upgrade and it isn't entirely obvious what to do about it. So the analyzer tells me that I need to add a reference to the new System.Reactive.WPF NuGet package. So let's go back and find that. And when I add this, I'm back in business.</p>
<p>So this provides source-level compatibility as long as you add the new package reference. And as I mentioned, we do actually provide binary-level compatibility by using a trick with reference assemblies to hide these types just at build time. So the need to add a new package is slightly annoying, but our view is that UI framework-specific support should really always have been an opt-in feature.</p>
<p>So this gets us finally to the place where we want to be. Now you might be wondering why it's taken so long to get here. I've been talking about this for two years now. We first opened a GitHub discussion on this back in November 2023, and we did produce a prototype just a few months later to show what it would look like in the hope of getting some feedback.</p>
<p>Now, we didn't get a lot, so we announced in October 2024 that we were gonna move forward, but that did produce some negative community feedback. The plan we had at the time was somewhat more radical and would've been more disruptive. Its end state would've avoided the weird trick we've had to use with reference assemblies.</p>
<p>And it would also have enabled us finally to remove the UAP target framework from the main Rx package. But it would also have created a lot of problems for people who weren't actually affected by the issue we were trying to fix. So we had a rethink and came up with a new plan. The critical difference is that now System.Reactive remains as the main assembly, meaning that a lot of Rx users shouldn't even notice this change.</p>
<p>We also introduced an extensive set of tests designed to find the kind of subtle problems that emerge with any attempt to fix this issue. We called that test suite "Rx Gauntlet," and that is what's given us the confidence to move forward with this new plan.</p>
<p>One last thing: last year I talked about Async Rx .NET. We have made a little progress on extending the test suite, but we considered the code bloat issue with Rx .NET to be of higher priority. We will be getting back to Async Rx .NET, and we hope to have a non-alpha release next year.</p>
<p>My name's Ian Griffiths. Thanks for listening.</p>]]></content:encoded>
    </item>
    <item>
      <title>C# 14 New Feature: Implicit Span Conversions</title>
      <description>&lt;p&gt;In this talk, &lt;a href="https://endjin.com/who-we-are/our-people/ian-griffiths/"&gt;Ian Griffiths&lt;/a&gt; dives into the new implicit conversions introduced in C# 14, designed to make span types more natural to use.&lt;/p&gt;
&lt;p&gt;He discusses how this change enhances performance, simplifies method signatures, and enables more powerful extension methods. However, Ian also warns about potential compatibility issues with older libraries and provides advice for library authors. He concludes with technical examples and solutions to common problems caused by these new features. Essential viewing for C# developers looking to leverage spans in their code more effectively.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;00:00 Introduction to Implicit Conversions in C# 14&lt;/li&gt;
&lt;li&gt;01:17 Understanding Span Types and Their Benefits&lt;/li&gt;
&lt;li&gt;02:35 Practical Examples of Using Spans&lt;/li&gt;
&lt;li&gt;05:04 Limitations and Issues in C# 13&lt;/li&gt;
&lt;li&gt;08:05 Improvements in C# 14&lt;/li&gt;
&lt;li&gt;12:42 New Implicit Span Conversions&lt;/li&gt;
&lt;li&gt;19:16 Potential Issues with Older Libraries&lt;/li&gt;
&lt;li&gt;23:33 Conclusion and Final Thoughts&lt;/li&gt;
&lt;/ul&gt;</description>
      <link>https://endjin.com/what-we-think/talks/csharp-14-new-feature-implicit-span-conversions</link>
      <guid isPermaLink="true">https://endjin.com/what-we-think/talks/csharp-14-new-feature-implicit-span-conversions</guid>
      <pubDate>Thu, 11 Dec 2025 06:30:00 GMT</pubDate>
      <category>.NET 10</category>
      <category>C# 14</category>
      <category>dotnet</category>
      <category>Implicit Span Conversions</category>
      <category>Open Source</category>
      <category>.NET Foundation</category>
      <category>Talk</category>
      <enclosure length="0" type="image/jpeg" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/csharp-14-new-feature-implicit-span-conversions.jpg" />
      <dc:creator>Ian Griffiths</dc:creator>
      <content:encoded><![CDATA[<p>In this talk, <a href="https://endjin.com/who-we-are/our-people/ian-griffiths/">Ian Griffiths</a> dives into the new implicit conversions introduced in C# 14, designed to make span types more natural to use.</p>
<p>He discusses how this change enhances performance, simplifies method signatures, and enables more powerful extension methods. However, Ian also warns about potential compatibility issues with older libraries and provides advice for library authors. He concludes with technical examples and solutions to common problems caused by these new features. Essential viewing for C# developers looking to leverage spans in their code more effectively.</p>
<ul>
<li>00:00 Introduction to Implicit Conversions in C# 14</li>
<li>01:17 Understanding Span Types and Their Benefits</li>
<li>02:35 Practical Examples of Using Spans</li>
<li>05:04 Limitations and Issues in C# 13</li>
<li>08:05 Improvements in C# 14</li>
<li>12:42 New Implicit Span Conversions</li>
<li>19:16 Potential Issues with Older Libraries</li>
<li>23:33 Conclusion and Final Thoughts</li>
</ul>
<p><a href="https://www.youtube.com/watch?v=C8yGHfk-puA"><img src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/csharp-14-new-feature-implicit-span-conversions.jpg"></a></p><p>C# 14 defines some new implicit conversions that make the use of span types feel more natural. In fact, the feature proposal's original title was "First Class Span Types." So this new feature is a change that should mostly go unnoticed. Things should just work the way you would expect. However, there are a couple of good reasons to know about the details.</p>
<p>One is that if you are using class libraries that were designed before C# 14, this change might cause problems. There are some circumstances in which code that used to compile just fine needs modification in C# 14 because you're using a library that made some assumptions that are no longer true. The resulting errors can be baffling if you don't understand the language changes that cause them.</p>
<p>Another good reason to understand these changes in detail is if you're a library author—this new feature might change certain design decisions. And there's another reason, which is more indirect, but was actually the first thing that caused a problem in my work: when library designers changed their libraries to adapt to this new feature, those library changes can cause errors when you recompile your code. But we'll get to that.</p>
<p>Just in case you've not come across .NET span types, they were introduced back in 2017 with C# version 7 and .NET Core 2.1, with two main goals. One was to make it much easier to write high-performance code. These types are what enable high-performance libraries like System.Text.Json to be so much faster and more efficient than older libraries. At Endjin, we wrote the AIS.NET libraries using spans and it delivered performance several orders of magnitude better than other libraries available at the time.</p>
<p>The other thing that spans do is make it easier to deal with all the different places data might live. An ordinary array object has to live on .NET's garbage-collected heap, but .NET provides ways to create arrays that live inside the call stack. And if we're interacting with operating system APIs or external libraries, we might want to use data that lives in regions of memory that are outside of .NET's control.</p>
<p>A span can handle all three scenarios. This means that we can write a single method that can work with data wherever it happens to be. Whether the data is on the .NET heap, the stack, or some other block of memory, a single method that takes a span can deal with all of these.</p>
<p>Here's an example. To keep it simple, this reports just the length of the data. We could do more interesting things, but this new language feature mainly affects how methods are invoked, so it's the method signature that matters here.</p>
<p>This line makes a span for some data that lives on the stack. It builds an array without creating work for the garbage collector. And as this shows, I can also get a span for a string.</p>
<p>A crucial feature of spans is that they don't copy data. They refer to the data wherever it may happen to sit. So this span does actually refer to the garbage-collected heap because I started with a string, and .NET string objects always live on the heap. But this line here shows one of the efficiency features of a span.</p>
<p>I can slice it. Here I said I want a span that refers to just the first five characters of the string. So conceptually, this is just like calling Substring, but the big advantage a span offers here is that it doesn't create a copy. Whereas the string type's Substring method creates a whole new string object on the heap containing a copy of the part you wanted, this refers to exactly the same string data as the previous span. It just remembers that we're only looking at the first five characters.</p>
<p>And this shows that if I've got an ordinary array on the heap, I can turn that into a span too. Again, this doesn't copy it; it just lets me refer to the data that's in an ordinary array using a span.</p>
<p>This flexibility means that a single method that takes a span will work for strings, arrays, or data that lives outside of the heap, and it will support efficient slicing of data. If I had written this method to take an array, it wouldn't work on strings or on stack-based data. And if I wanted to slice the input up, I'd need to allocate a new smaller array with a copy of the data. So it often makes more sense to define APIs that take spans instead of ones that take arrays or strings.</p>
<p>It's also quite common to define extension methods for spans. So I could, if I wanted, implement this functionality as an extension method instead. As you can see here—now, you may have deduced from the file names that this is all C# 13. So if I can do all this in C# 13, why did C# 14 need to change anything?</p>
<p>So now it's time to look at some things that don't work in C# 13. When I passed an array to this first method, I first assigned it into a variable of type ReadOnlySpan of int, which shows us that an implicit conversion is available. So you might think I can just pass the array directly to the method, but I got an error.</p>
<p>It turns out that the compiler can't infer the right type argument. If I provide the type argument, then it works. So it's possible, but a bit inconvenient. And if I try to pass a string directly, it's the same story. A suitable implicit conversion exists, but I need to provide the type argument explicitly for the compiler to understand what I want.</p>
<p>What about the extension method? When I tried that, it doesn't work. The compiler error tells us that it's looking for a method accepting a first argument of type int array. In fact, the method will accept that if I call it explicitly, instead of as an extension method.</p>
<p>Again, I need to provide the type argument. So maybe that was the problem with the extension method, but if I supply the type argument while trying to use this as an extension method, it still doesn't work. So it has actually found the extension method, but it's telling me that for this code to work, the extension method requires a receiver of type array.</p>
<p>And this is true. The rules in C# for when a method can be used as an extension method are slightly more restrictive than the rules for normal method invocation. When you use normal method invocation, then if the argument types don't match, the compiler will look for user-defined implicit conversions, and that's what's happening here.</p>
<p>The span type defines a custom implicit conversion from arrays, and the compiler has used that to make this method call work. And if I open up the compiled code in ILSpy, and if I find the main method—that call to ShowLengthExt is the last thing I did—and if I scroll down to the end, you can see that it is indeed invoking the span-defined custom implicit conversion operator.</p>
<p>So why doesn't it do the same thing when I try to invoke the same method with the extension method syntax? Well, I said the rules for extension methods are more restrictive, and one of the differences is that C# won't look for user-defined implicit conversion operators to try to make an extension method invocation work.</p>
<p>So how does C# 14 change all of this? I'll keep the C# 13 version open on the left so you can see how things change in C# 14. Let's start with the first method, so not the extension method. If I'm using a stack-allocated array, I now don't need to assign that into a span variable first. I can just pass it directly.</p>
<p>This code only works on C# 14. Although in practice it's not a big win because actually there's a simpler way to achieve this. Visual Studio suggests that I use the collection expression syntax, which is a more compact way to do exactly the same thing as this code. And if I were to accept that change, the result would actually work in C# 13 as well.</p>
<p>So although the new language feature happens to enable this example, it's only interesting insomuch as it helps us understand what's changed. Now, although C# 14 does define a new implicit conversion from string to span, it doesn't actually affect the particular example I wrote here. I'll show the impact later, but it doesn't change this bit here.</p>
<p>But there is a useful improvement with arrays. In the C# 14 example, I've removed the span variable because now when I pass an array to a generic method that takes a span, I no longer need to specify the type argument to make it work.</p>
<p>But the main goal with this language change is actually to make extension methods work better. So now I can use the extension method directly on the array, and that's the most important thing that these language changes enable.</p>
<p>So what's actually changed? Well, what they didn't do was just allow extension method resolution to apply custom implicit conversions to the receiver. That might seem like it would've been the obvious thing to do if your starting point is this code—I've declared read-only local variables to make use of the custom implicit conversions that ReadOnlySpan defines.</p>
<p>So it's tempting to say that I want the compiler to just do that work for me and use these same conversions automatically for extension methods. But this would be a quite broad change with the potential to change the meaning of a lot of existing code. If the compiler suddenly starts considering using implicit conversions in cases where it never used to, previously correct code might become ambiguous, causing compiler errors.</p>
<p>It may well be possible to create rules that would enable this to work while avoiding such problems. But the C# team's goal here wasn't to enable a whole new way of using extension methods. Their goal was much more focused. They wanted to make it easier to use spans. Again, the feature proposal's original title was "First Class Span Support."</p>
<p>So although the actual mechanisms are some changes to the type conversion rules, the intention is absolutely focused on spans. This feels like a slightly unusual move to me. Normally, C# has tended to prefer generality. For example, the motivation for LINQ was better language-level support for working with databases, but the set of language features that enabled LINQ are all far more general in nature.</p>
<p>So it surprised me a little to see the C# designers choose a narrower mechanism when a more general one might have solved the same problem. Now having read through all the public discussions that I could find about this feature, it looks to me like the C# team wanted the language to embody the quite specific relationship between arrays and spans, and that they concluded that general-purpose features would either be unable to capture the nuances or would be excessively complex.</p>
<p>And I think that's why they didn't simply allow implicit conversions to apply to extension method receivers. That would be a very general mechanism, but it provides nowhere to build in any special understanding of how spans and arrays relate. That said, I still don't have a clear idea of any specific scenarios that would've turned out worse if they'd done the more general thing.</p>
<p>In the public discussions I found where people asked if they couldn't just make user-defined implicit conversions available for extension method receivers, the C# team's responses stated the design philosophy without giving specific examples of why it was better. But anyway, to make this work, they've added a completely new kind of built-in implicit conversion to the language specification.</p>
<p>In C# 14, we now have something called an implicit span conversion. The rules for implicit span conversions directly reflect the relationship between arrays and spans. So specifically we have conversions to Span of T for any array of type T. We also have conversion to ReadOnlySpan of T, but this goes further. This supports covariant conversion.</p>
<p>So for example, if you've got an array of strings, not only is that convertible to a ReadOnlySpan of string, it's also convertible to a ReadOnlySpan of object because there's an implicit reference conversion from string to object. Also, since string implements IComparable, this new language feature also makes an implicit conversion from array of string to ReadOnlySpan of IComparable, and likewise for any other interface that string implements.</p>
<p>This new language feature also defines covariant conversion from Span to ReadOnlySpan. These covariant conversions are examples of how this language feature embeds the similarities between spans and arrays.</p>
<p>The final new implicit conversion is from string to ReadOnlySpan of char. Now, if you're familiar with spans, you might be perplexed at this stage because the runtime library span types already define implicit conversion operators for some of these conversions.</p>
<p>And it's true that some of the cases where these new conversions apply used to work in C# 13 without any special compiler handling. The runtime library defines the Span and ReadOnlySpan types with various user-defined implicit conversions, and the compiler applies these in exactly the same way as it handles custom implicit conversions for other types.</p>
<p>So what then is the point of these language changes? The key is that these implicit conversions are now a different kind from ordinary custom implicit conversions, meaning the language can define special rules for them. For example, this new feature adds some new type inference rules specifically for the case where an array is supplied where either a Span or ReadOnlySpan is expected.</p>
<p>This is why in C# 14 we no longer need to provide a type argument when passing an array to a method that accepts a span. In C# 13, the only way I could pass an array expression to my span-based method was to supply the type argument. The type inference rules aren't able to determine that a user-defined conversion could work here.</p>
<p>But in C# 14, there's an explicit inference rule for when an array is passed where a span is expected. So when I do that here, it correctly infers that the type argument should be the array element type, and it can then apply the built-in conversion from array to ReadOnlySpan.</p>
<p>This new language feature also changes the rules for extension methods. These new implicit span conversions are now in the list of conversions that the compiler will consider when trying to decide whether an extension method is applicable. That's why I can call my span-based extension method directly on the array in C# 14. The new rules mean that when trying to resolve this method, the compiler will consider the new implicit conversions from arrays to spans, which means that my ShowLengthExt method is now a candidate, which it wasn't in C# 13.</p>
<p>So we have these new built-in implicit conversions and some changes to the rules for extension method resolution. This enables the most important feature: extension methods for spans now work like you'd hoped they would with arrays.</p>
<p>But there's more. As well as affecting method resolution, the changes also open up some new scenarios around user-defined conversions. As you may know, C# won't chain together an unlimited number of user-defined implicit conversions. If you define a type called Stringable with an implicit conversion to string, the compiler will let you assign a Stringable into a variable of type string, or pass it as an argument to a method accepting a string.</p>
<p>Now, as I already mentioned, it won't use this implicit conversion for the implicit receiver argument of an extension method, but user-defined implicit conversions are available for ordinary arguments. But if we then define another type that has an implicit conversion to Stringable, which I suppose we would call Stringableable, then although we can assign a Stringableable directly into a variable of type Stringable, and we can assign a Stringable into a variable of type string, we can't assign a Stringableable directly into a string.</p>
<p>The compiler won't string the conversions together, so to speak. Essentially you get to use just one user-defined conversion within an expression.</p>
<p>Now, before C# 14, the implicit conversions available for spans were implemented as user-defined conversions in the usual way by the span types, and these effectively used up your quota.</p>
<p>Let me show you what I mean. This class defines a custom conversion from ReadOnlySpan of object. Here I've defined an object array. Now in C# 13, I can assign that into a ReadOnlySpan of object because ReadOnlySpan defines a user-defined implicit conversion operator, and then I can assign that span into a variable of type WithImplicit because of this conversion operator.</p>
<p>So we've effectively assigned an array of objects into a WithImplicit variable by going through a couple of implicit conversion operators. But if I try to go there directly, it doesn't work. The compiler won't discover the chain of custom conversion operators required to get there. Each assignment effectively has to use just one implicit conversion at a time.</p>
<p>But in C# 14, I can go straight from the array to my type, and that's because the conversion to span is now a built-in implicit conversion, meaning that I'm only attempting a single user-defined conversion in this expression.</p>
<p>Earlier I mentioned that this change can cause problems when you use old libraries written before this feature was added. This can happen when the new built-in implicit conversions mean that method overloads that were not previously applicable now become candidates. Code that used to compile without problems can end up being ambiguous.</p>
<p>For example, xUnit.NET had a potential problem. Now, in fact, they fixed this before .NET 10 shipped. But if you're using older versions of their libraries, you could still encounter this.</p>
<p>So the problem was that these two overloads existed of the Assert.Equals method. Now suppose you wrote a test containing this code. In C# 13, this would use the first overload. But now in C# 14, the built-in implicit conversion from array to span means that these two methods are considered equally good candidates. And so this is ambiguous.</p>
<p>As I said, they have already fixed this, but if you're a library author, how would you fix these kinds of problems in your own code? Well, if you've got a situation like the one just described, you've got a couple of options. You could define a new overload that the C# compiler will consider to be a better match than either of the existing ambiguous options.</p>
<p>For example, in the xUnit example I just described, one way to resolve the problem would be to add an overload that accepts a ReadOnlySpan as its first argument and an array as its second. This is a perfect match for the code I showed, so the compiler no longer considers using the other two options because those are both less direct.</p>
<p>Another possibility is to use the OverloadResolutionPriority attribute. You can put this on a method to break ties in cases that would normally be ambiguous.</p>
<p>Now there's one more way in which this new language feature could cause you problems. And ironically, it occurs when a library has been modified specifically to provide better support for spans. Now, we at Endjin actually ran into this in the Reactive Extensions for .NET, an open-source project that Endjin maintains.</p>
<p>The .NET runtime libraries define two extension methods, both called Reverse. Now they're designed for different scenarios. One is a standard LINQ operator, and LINQ to Objects defines this for any type that implements IEnumerable of T. This is a non-destructive method. It doesn't change its target. Instead, it returns a brand new object which provides all the values from the source object but in reverse order. So it's just a view over the underlying source.</p>
<p>But the MemoryExtensions class also defines a method called Reverse as an extension method for Span of T. And this is an in-place operation that modifies its target.</p>
<p>Now a couple of unit tests in the Reactive Extensions code base included expressions like this. Now, our intention here was to get an IEnumerable of char that enumerates the characters in a string in reverse order. And before C# 14, this resolved unambiguously to the IEnumerable of T extension.</p>
<p>But remember, one of the main goals of this new language feature is to enable extension methods to work on expressions whose type is not a span but which can be implicitly converted to a span. So in this case, that makes the span flavour of Reverse a viable candidate.</p>
<p>Now, this is a somewhat unusual example because these aren't really overloads of the same thing. They're two completely different methods that happen to have the same name. So despite what duck-typing advocates would have you believe, names alone do not always fully identify methods.</p>
<p>Now it's easy to fix. We just insert a call to AsEnumerable to force the array into an IEnumerable of char and now the overload we want is an exact match, so the error goes away. This particular form of problem is likely to be fairly unusual because it's just not all that common to get this kind of name collision.</p>
<p>So there it is. Whereas we used to have to rely on the custom implicit conversion operators that the span types defined, the language now defines built-in implicit conversions. These are designed specifically to express the relationship between spans and arrays, providing more precisely targeted support than would otherwise be possible. And this also enables span-based extension methods to be more widely applicable.</p>
<p>My name's Ian Griffiths. Thanks for listening.</p>]]></content:encoded>
    </item>
    <item>
      <title>How .NET 10.0 boosted AIS.NET performance by 7%</title>
      <description>.NET 10.0 has shipped, and for the fifth year running, we benchmarked endjin's AIS.NET library and were very happy to see substantial performance gains, with no extra work required.</description>
      <link>https://endjin.com/blog/how-dotnet-10-boosted-ais-dotnet-performance-by-7-percent-for-free</link>
      <guid isPermaLink="true">https://endjin.com/blog/how-dotnet-10-boosted-ais-dotnet-performance-by-7-percent-for-free</guid>
      <pubDate>Tue, 09 Dec 2025 06:30:00 GMT</pubDate>
      <category>C# 14.0</category>
      <category>C# 14</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET 10.0</category>
      <category>dotnet</category>
      <category>C# Tutorials</category>
      <category>C# Programming</category>
      <category>High Performance</category>
      <category>Programming C# 12.0</category>
      <category>low-allocation</category>
      <category>ref struct</category>
      <category>Span&lt;T&gt;</category>
      <category>ReadOnlySpan&lt;T&gt;</category>
      <category>Ais.Net</category>
      <category>aisdotnet</category>
      <category>AIS</category>
      <category>Automatic identification System</category>
      <category>Parser</category>
      <category>Decoder</category>
      <category>Marine</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/12/aisdotnetperfnet10.0.png" />
      <dc:creator>Ian Griffiths</dc:creator>
      <content:encoded><![CDATA[<p>TLDR; .NET 10.0 increased performance in our <a href="https://github.com/ais-dotnet/Ais.Net/">Ais.NET library</a> by 7% with no code changes. Performance is well over twice as fast as it was on .NET Core 3.1 when we first released this library. A Surface Laptop Studio 2 can process 10.14 million messages per second!</p>
<p>At endjin, we maintain <a href="https://github.com/ais-dotnet/Ais.Net/">Ais.Net</a>, an open source <a href="https://endjin.com/blog/introducing-ais-dotnet-high-performance-ais-parsing-in-csharp">high-performance library for parsing AIS messages</a> (the radio messages that ships broadcast to report their location, speed, etc.). Each time a new version of .NET ships, we check it all still works and then run our benchmarks again. Each year, we've seen significant improvements:</p>
<ul>
<li><a href="https://endjin.com/blog/how-dotnet-6-boosted-ais-dotnet-performance-by-20-percent-for-free">.NET 6.0: 20%</a></li>
<li><a href="https://endjin.com/blog/how-dotnet-7-boosted-ais-dotnet-performance-by-19-percent-for-free">.NET 7.0: 19%</a></li>
<li><a href="https://endjin.com/blog/how-dotnet-8-boosted-ais-dotnet-performance-by-27-percent-for-free">.NET 8.0: 27%</a></li>
<li><a href="https://endjin.com/blog/how-dotnet-9-boosted-ais-dotnet-performance-by-9-percent-for-free">.NET 9.0: 9%</a></li>
</ul>
<p>So what about .NET 10.0? The short answer is that yet again it is significantly faster. For continuity I have run the benchmarks on the same desktop computer as when I first started benchmarking this library, meaning these figures are all directly comparable.</p>
<p>For the fifth year running, we're enjoying a free lunch! Without making any changes whatsoever to our code, our benchmarks improved by roughly 7% simply by running the code on .NET 10.0 instead of .NET 9.0. As with last time, we've not had to release a new version—the existing version published on NuGet (which targets <code>netstandard2.0</code> and <code>netstandard2.1</code>) runs faster just as a result of upgrading your application to .NET 10.0.</p>
<p>Admittedly, this year's improvement is the smallest yet. But if you had asked me back in 2019 when we first wrote this library whether I'd expect to see each subsequent release of .NET make the library run faster and faster, with the aggregate improvement making the library run over 2.1x faster, I would have been sceptical.</p>
<p>Our memory usage is roughly the same. Our amortized allocation cost per record continues to be 0 bytes. The total memory usage including startup costs is very similar: a handful of kilobytes, depending on exactly which features you use.</p>
<h2 id="benchmark-results">Benchmark results</h2>
<p>We have two benchmarks. One measures the maximum possible rate of processing messages, while doing as little work as possible for each message. This is not entirely realistic, but it's useful because it establishes the upper bound on how fast an application can process AIS messages on a single thread. The second benchmark uses a slightly more realistic workload, inspecting several properties from each message. Each benchmark runs against a file containing one million AIS records.</p>
<h3 id="net-8.0">.NET 8.0</h3>
<p>When I tested on <a href="https://endjin.com/blog/how-dotnet-8-boosted-ais-dotnet-performance-by-27-percent-for-free">.NET 8.0 in November 2023</a>, I saw the results shown in this next table when running the benchmarks on my desktop. These figures correspond to an upper bound of 5.72 million messages per second, and a processing rate of 4.75 million messages a second for the slightly more realistic example. (The desktop I've run all these benchmarks on is now about 8 years old, and it has an Intel Core i9-9900K CPU.)</p>
<table>
<thead>
<tr>
<th>Method</th>
<th style="text-align: right;">Mean</th>
<th style="text-align: right;">Error</th>
<th style="text-align: right;">StdDev</th>
<th style="text-align: right;">Allocated</th>
</tr>
</thead>
<tbody>
<tr>
<td>InspectMessageTypesFromNorwayFile1M</td>
<td style="text-align: right;">174.7 ms</td>
<td style="text-align: right;">2.20 ms</td>
<td style="text-align: right;">2.06 ms</td>
<td style="text-align: right;">4 KB</td>
</tr>
<tr>
<td>ReadPositionsFromNorwayFile1M</td>
<td style="text-align: right;">210.5 ms</td>
<td style="text-align: right;">4.15 ms</td>
<td style="text-align: right;">4.08 ms</td>
<td style="text-align: right;">5 KB</td>
</tr>
</tbody>
</table>
<h3 id="net-9.0">.NET 9.0</h3>
<p>These were the numbers for <a href="https://endjin.com/blog/how-dotnet-9-boosted-ais-dotnet-performance-by-9-percent-for-free">.NET 9.0</a>. The upper bound is 6.38 million messages per second, and the more realistic example processes 5.20 million messages per second.</p>
<table>
<thead>
<tr>
<th>Method</th>
<th style="text-align: right;">Mean</th>
<th style="text-align: right;">Error</th>
<th style="text-align: right;">StdDev</th>
<th style="text-align: right;">Allocated</th>
</tr>
</thead>
<tbody>
<tr>
<td>InspectMessageTypesFromNorwayFile1M</td>
<td style="text-align: right;">156.7 ms</td>
<td style="text-align: right;">1.04 ms</td>
<td style="text-align: right;">0.97 ms</td>
<td style="text-align: right;">4.13 KB</td>
</tr>
<tr>
<td>ReadPositionsFromNorwayFile1M</td>
<td style="text-align: right;">192.3 ms</td>
<td style="text-align: right;">1.33 ms</td>
<td style="text-align: right;">1.18 ms</td>
<td style="text-align: right;">4.13 KB</td>
</tr>
</tbody>
</table>
<p>I repeated these tests just now on the very latest .NET 8.0 and .NET 9.0 runtimes to check that my hardware setup hadn't changed in a way that was affecting performance. (We've been benchmarking all the way back to .NET Core 2, but I only repeat the measurements on runtimes still in support.) Within the bounds of experimental noise, the results were essentially the same. (That's what you'd hope, given that this is running on the same hardware, but the .NET runtime does get regular updates, so it's worth checking performance has remained the same on those versions. It's also important to check that I've not done something to my machine to change its performance. In fact, first time I re-ran these, I got slower numbers. Turns out I hadn't been as thorough as I meant to be when shutting down other processes to get the machine as close to idle as possible. So it was well worth repeating the measurements for older runtimes—otherwise I'd have been making .NET 10.0 look less good than it is.)</p>
<h3 id="net-10.0">.NET 10.0</h3>
<p>And now, the .NET 10.0 numbers:</p>
<table>
<thead>
<tr>
<th>Method</th>
<th style="text-align: right;">Mean</th>
<th style="text-align: right;">Error</th>
<th style="text-align: right;">StdDev</th>
<th style="text-align: right;">Allocated</th>
</tr>
</thead>
<tbody>
<tr>
<td>InspectMessageTypesFromNorwayFile1M</td>
<td style="text-align: right;">148.1 ms</td>
<td style="text-align: right;">0.82 ms</td>
<td style="text-align: right;">0.77 ms</td>
<td style="text-align: right;">4.14 KB</td>
</tr>
<tr>
<td>ReadPositionsFromNorwayFile1M</td>
<td style="text-align: right;">179.3 ms</td>
<td style="text-align: right;">2.45 ms</td>
<td style="text-align: right;">2.30 ms</td>
<td style="text-align: right;">4.14 KB</td>
</tr>
</tbody>
</table>
<p>This shows that on .NET 10.0, our upper bound moves up to 6.75 million messages per second, while the processing rate for the more realistic example goes up to 5.58 million messages per second. Those are improvements of 6% and 7% respectively from .NET 9.0. (I put the 7% figure in the blog title because that benchmark better represents what a real application might do. I've done this in previous years regardless of which of the two benchmarks showed the larger of the two increases.)</p>
<h3 id="surface-laptop-studio-2">Surface Laptop Studio 2</h3>
<p>You might be wondering where the 10.14 million messages per second figure in the opening paragraph came from. That's from running the same benchmark on newer hardware. I use my old desktop to get a consistent view of performance changes over time, but it understates what's possible on current hardware. Here are the numbers from my laptop (a Surface Laptop Studio 2 with a 13th gen Intel Core i7-13800H):</p>
<table>
<thead>
<tr>
<th>Method</th>
<th style="text-align: right;">Mean</th>
<th style="text-align: right;">Error</th>
<th style="text-align: right;">StdDev</th>
<th style="text-align: right;">Allocated</th>
</tr>
</thead>
<tbody>
<tr>
<td>InspectMessageTypesFromNorwayFile1M</td>
<td style="text-align: right;">98.62 ms</td>
<td style="text-align: right;">0.542 ms</td>
<td style="text-align: right;">0.507 ms</td>
<td style="text-align: right;">3.93 KB</td>
</tr>
<tr>
<td>ReadPositionsFromNorwayFile1M</td>
<td style="text-align: right;">114.41 ms</td>
<td style="text-align: right;">0.753 ms</td>
<td style="text-align: right;">0.704 ms</td>
<td style="text-align: right;">3.93 KB</td>
</tr>
</tbody>
</table>
<p>That gives us 10.14 million messages per second for the basic inspection, and 8.74 million messages per second with the more realistic workload.</p>
<h2 id="free-performance-gains-over-time">Free performance gains over time</h2>
<p>The bottom line is that just as moving your application onto .NET 9.0 may well have given you an instant performance boost with no real effort on your part (as did moving to .NET 8.0, before that, and .NET 7.0, before that, and .NET 6.0 before that) you may enjoy a similar boost upgrading to .NET 10.0.</p>
<p>We've been running these benchmarks across 7 versions of .NET now (.NET Core 2, .NET Core 3.1, .NET 6.0, .NET 7.0, .NET 8.0, .NET 9.0, and .NET 10.0) enabling us to visualize how performance has improved across these releases for our library. First we'll look at the time taken to process 1 million AIS messages:</p>
<div class="chart-container" style="max-width:800px;margin:1rem auto;">
  <canvas id="chart-processing-time"></canvas>
  <noscript>
    <img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/12/ais-dotnet-net-10-messages-time.png" alt="Bar chart showing processing time in ms to process 1 million AIS messages across .NET versions" class="chart-fallback" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/12/ais-dotnet-net-10-messages-time.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/12/ais-dotnet-net-10-messages-time.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/12/ais-dotnet-net-10-messages-time.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/12/ais-dotnet-net-10-messages-time.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw">
  </noscript>
</div>

<p>(I've gone back to showing the figures for my aging desktop, to present a consistent history which is why this and the next graph don't show us breaking through the 10 million messages per second boundary.)</p>
<p>Each .NET release has brought improvements, so let's look at the version-over-version performance gains:</p>
<div class="chart-container" style="max-width:800px;margin:1rem auto;">
  <canvas id="chart-version-improvement"></canvas>
  <noscript>
    <img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/12/ais-dotnet-net-10-version-improvement.png" alt="Bar chart showing version-over-version performance improvement percentages" class="chart-fallback" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/12/ais-dotnet-net-10-version-improvement.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/12/ais-dotnet-net-10-version-improvement.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/12/ais-dotnet-net-10-version-improvement.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/12/ais-dotnet-net-10-version-improvement.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw">
  </noscript>
</div>

<p>And next, the throughput in AIS messages per second (same benchmarks, just a different way to present the results):</p>
<div class="chart-container" style="max-width:800px;margin:1rem auto;">
  <canvas id="chart-throughput"></canvas>
  <noscript>
    <img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/12/ais-dotnet-net-10-messages-rate.png" alt="Bar chart showing AIS messages processed per second across .NET versions" class="chart-fallback" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/12/ais-dotnet-net-10-messages-rate.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/12/ais-dotnet-net-10-messages-rate.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/12/ais-dotnet-net-10-messages-rate.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/12/ais-dotnet-net-10-messages-rate.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw">
  </noscript>
</div>

<p>Finally, let's look at the cumulative performance improvements since .NET Core 2.0:</p>
<div class="chart-container" style="max-width:800px;margin:1rem auto;">
  <canvas id="chart-cumulative"></canvas>
  <noscript>
    <img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/12/ais-dotnet-net-10-cumulative.png" alt="Line chart showing cumulative performance gains since .NET Core 2.0" class="chart-fallback" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/12/ais-dotnet-net-10-cumulative.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/12/ais-dotnet-net-10-cumulative.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/12/ais-dotnet-net-10-cumulative.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/12/ais-dotnet-net-10-cumulative.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw">
  </noscript>
</div>

<p>Over the time the AIS.NET library has existed, performance has improved by well over double, thanks entirely to improvements in the .NET runtime!</p>
<h2 id="learn-more-about-ais.net">Learn more about AIS.NET</h2>
<p>You can learn more about our Ais.Net library at the GitHub repo, <a href="http://github.com/ais-dotnet/Ais.Net/">http://github.com/ais-dotnet/Ais.Net/</a> and in the same <a href="http://github.com/ais-dotnet/">ais-dotnet GitHub organisation</a> you'll also find some other layers, as illustrated in this diagram:</p>
<p><img src="https://endjincdn.blob.core.windows.net/assets/ais-dotnet-project-layers.png" alt="A diagram showing the Ais.Net library layering as three rows. The top row provides this description of Ais.Net.Receiver: AIS ASCII data stream ingestion using IAsyncEnumerable. Decoded message available via IObservable. The second row provides this description of Ais.Net.Models: Immutable data structures using C# 9.0 Records. Interface expose domain concepts such as position. The third row provides this description of Ais.Net: high performance, zero-allocation decode using Span<T>. ~3 million messages per second per core."></p>
<p>Note that there is a separate repository for <a href="https://github.com/ais-dotnet/Ais.Net.Models"><code>Ais.Net.Models</code></a>. And there's another for the <a href="https://github.com/ais-dotnet/Ais.Net.Receiver/"><code>Ais.Net.Receiver</code></a> project. If you would like to experiment with this library, you will find some <a href="https://code.visualstudio.com/docs/languages/polyglot">polyglot notebooks</a> illustrating its use at <a href="https://github.com/ais-dotnet/Ais.Net.Notebooks">https://github.com/ais-dotnet/Ais.Net.Notebooks</a></p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">AIS.NET</h3>
        <span class="series-toc__count">7 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/introducing-ais-dotnet-high-performance-ais-parsing-in-csharp" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Introducing AIS.NET</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/arraypool-vs-memorypool-minimizing-allocations-ais-dotnet" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Minimizing Allocations</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/how-dotnet-6-boosted-ais-dotnet-performance-by-20-percent-for-free" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">.NET 6.0's 20% Perf Boost</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/how-dotnet-7-boosted-ais-dotnet-performance-by-19-percent-for-free" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">.NET 7.0's 19% Perf Boost</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/how-dotnet-8-boosted-ais-dotnet-performance-by-27-percent-for-free" class="series-toc__link">
                    <span class="series-toc__part-number">5.</span>
                    <span class="series-toc__part-title">.NET 8.0's 27% Perf Boost</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/how-dotnet-9-boosted-ais-dotnet-performance-by-9-percent-for-free" class="series-toc__link">
                    <span class="series-toc__part-number">6.</span>
                    <span class="series-toc__part-title">.NET 9.0's 9% Perf Boost</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">7.</span>
                <span class="series-toc__part-title">.NET 10.0's 7% Perf Boost</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>C# 14 New Feature: Field-Backed Properties</title>
      <description>&lt;p&gt;In this talk, &lt;a href="https://endjin.com/who-we-are/our-people/ian-griffiths/"&gt;Ian Griffiths&lt;/a&gt; explains how C# 14's new field-backed properties feature can save you from metaphorically falling off a cliff when you need more flexibility beyond automatic properties' basic functionality.&lt;/p&gt;
&lt;p&gt;He demonstrates the use of this feature to customize property setters without losing the simplicity and support of automatic properties. By allowing you to refer to the compiler-generated field inside get or set methods, C# 14 reduces verbosity and maintains code clarity and organization.&lt;/p&gt;
&lt;p&gt;Learn how this small but impactful enhancement can improve your C# coding experience.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;00:00 Introduction to C# 14's New Feature&lt;/li&gt;
&lt;li&gt;00:30 Understanding Automatic Properties&lt;/li&gt;
&lt;li&gt;01:11 Customizing Property Behavior&lt;/li&gt;
&lt;li&gt;03:06 Introducing C# 14's New Syntax&lt;/li&gt;
&lt;li&gt;04:21 Benefits of the New Feature&lt;/li&gt;
&lt;li&gt;05:33 Conclusion&lt;/li&gt;
&lt;/ul&gt;</description>
      <link>https://endjin.com/what-we-think/talks/csharp-14-new-feature-field-backed-properties</link>
      <guid isPermaLink="true">https://endjin.com/what-we-think/talks/csharp-14-new-feature-field-backed-properties</guid>
      <pubDate>Wed, 03 Dec 2025 06:30:00 GMT</pubDate>
      <category>.NET 10</category>
      <category>C# 14</category>
      <category>dotnet</category>
      <category>Field-Backed Properties</category>
      <category>Automatic Properties</category>
      <category>Open Source</category>
      <category>.NET Foundation</category>
      <category>Talk</category>
      <enclosure length="0" type="image/jpeg" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/csharp-14-new-features-field-backed-properties.jpg" />
      <dc:creator>Ian Griffiths</dc:creator>
      <content:encoded><![CDATA[<p>In this talk, <a href="https://endjin.com/who-we-are/our-people/ian-griffiths/">Ian Griffiths</a> explains how C# 14's new field-backed properties feature can save you from metaphorically falling off a cliff when you need more flexibility beyond automatic properties' basic functionality.</p>
<p>He demonstrates the use of this feature to customize property setters without losing the simplicity and support of automatic properties. By allowing you to refer to the compiler-generated field inside get or set methods, C# 14 reduces verbosity and maintains code clarity and organization.</p>
<p>Learn how this small but impactful enhancement can improve your C# coding experience.</p>
<ul>
<li>00:00 Introduction to C# 14's New Feature</li>
<li>00:30 Understanding Automatic Properties</li>
<li>01:11 Customizing Property Behavior</li>
<li>03:06 Introducing C# 14's New Syntax</li>
<li>04:21 Benefits of the New Feature</li>
<li>05:33 Conclusion</li>
</ul>
<p><a href="https://www.youtube.com/watch?v=aAMQOBZA2bI"><img src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/csharp-14-new-features-field-backed-properties.jpg"></a></p><p>C# 14 can save you from falling off a cliff with its new field-backed properties feature. Admittedly, the cliff is metaphorical. Sometimes when you're using a language or library feature, you can find yourself wanting to go beyond what that feature is able to support. And by moving outside the bounds of its support, you, so to speak, walk off the cliff and that sudden loss of support makes your life a lot harder.</p>
<p>Let me show you what I mean. I've got a very simple class here with a couple of properties. As you may know, this syntax where we use just the get and set keywords and optionally an accessibility modifier makes the compiler generate some code for us. It'll define a hidden field to hold the value and it supplies bodies for the get and set that use that field. The proper name for this is an automatically implemented property, but we typically shorten that to just auto property. This saves us from the tedious business of declaring a field and writing the obvious code to read and write the value in that field. It's not a huge deal, but if you're writing lots of properties, this offers worthwhile improvements in clarity and reduces work.</p>
<p>But what if we want slightly more than what C# generates for us? Notice this type defines an IsModified property. What if I want to set that anytime the Value property changes? Before C# 14, the only way to do that was to write a full property instead of an automatic property. Visual Studio can make that change for me. As you can see, this means declaring the field explicitly and having get and set accessors that use that field. Actually, Visual Studio doesn't quite get it right in this case because it hasn't noticed that the field name collides with the contextual keyword value inside the getter. So I need to qualify the field with a this reference.</p>
<p>And now this is almost identical to what the compiler was generating for me. I can now add in the extra feature that I wanted. So I'm just gonna adjust the layout and then use the full block syntax for the setter. And that gives me a place to put the code that sets the IsModified flag. Let's just run that and check that it worked.</p>
<p>And you can see that after I've set the Value property, the IsModified flag reflects that change as required. The obvious downside is that this is more verbose. It's not terrible. I can't complain about the fact that I've had to write the setter explicitly. The goal here was to customize that, but I've also got an explicitly implemented getter, which is effectively identical to what the compiler was generating, and I've also now got this field.</p>
<p>It's only a slight increase in clutter, but perhaps more concerning is the fact that it would be possible for other code in this class to use this field directly, bypassing my change detection. So the cliff wasn't a big one, it's a bit jarring, but this isn't a major problem. However, it comes up often enough that the C# team decided to support scenarios like this without forcing you to stop using automatic properties entirely.</p>
<p>In C# 14, I can leave the automatic get exactly as it is because I didn't actually wanna change that. Here I can customize just the setter. I can add this extra feature setting the IsModified flag. But how do I modify the value? Well, this is where I use the new syntax. Inside a get or set method, I can use the field keyword to refer to the compiler-generated field.</p>
<p>Let me just change the startup project and running that again, we get the required behavior. Comparing this to what we had to do before, you can see that this is a relatively small change. Before, I needed to declare my own field if I wanted to customize my property behavior, but now I can still get the compiler to generate that field for me.</p>
<p>Before, I had to write a custom getter, even though it was only the setter that I wanted to change. Now I can continue to use the compiler-generated getter. So this new feature has a fairly small impact, but what I prefer about the new code is that it removes clutter. I can see immediately that the getter does nothing out of the ordinary, so it's easy to see the one thing that makes this property slightly unusual.</p>
<p>The other benefit is I've not had to introduce a field. And while that's just one line of code, it's a line that doesn't sit all that well with some widely used .NET style guidelines that require fields to be declared in a separate part of the code from properties. Often the field and property could end up being quite distant, and when conceptually closely related code gets scattered across a file, it increases the work required to understand the code.</p>
<p>Arguably that's a flaw in the coding style guidelines, but for better or worse, it's a very common style in .NET that does provide benefits in some scenarios. Also, by using the compiler-generated field, I can be sure that the only code that modifies the field directly is this line here, and with the old approach, I'd need to search for other uses of the field to understand whether any code elsewhere in the class might be bypassing this change detection.</p>
<p>So in conclusion, C# 14 enables us to continue to enjoy the benefits of automatic properties, even when we move beyond their basic capabilities. So automatic properties have always given us a concise way to get basic property behavior, but now if we want to extend beyond that behavior, we can do so without having to fall off the cliff of support.</p>
<p>My name's Ian Griffiths. Thanks for listening.</p>]]></content:encoded>
    </item>
    <item>
      <title>C# 14 New Feature: Script Directives</title>
      <description>&lt;p&gt;C# 14 introduces new directives that transform C# into a true scripting language. In this video, Ian Griffiths explains how the .NET 10 SDK now lets you run a single C# source file directly—no project file required.&lt;/p&gt;
&lt;p&gt;What you'll learn:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;How to run C# files directly with &lt;code&gt;dotnet run&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;The shebang (&lt;code&gt;#!&lt;/code&gt;) directive for Unix/Linux script execution&lt;/li&gt;
&lt;li&gt;The new &lt;code&gt;#:&lt;/code&gt; directive for adding NuGet packages and build properties&lt;/li&gt;
&lt;li&gt;How these features build on the low-ceremony entry points introduced in C# 9&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Contents:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;00:00 Introduction to C# 14 Scripting Capabilities&lt;/li&gt;
&lt;li&gt;00:10 Running C# Source Files with .NET 10 SDK&lt;/li&gt;
&lt;li&gt;00:32 Simplifying C# Program Structure&lt;/li&gt;
&lt;li&gt;01:47 Shebang Syntax for Unix Systems&lt;/li&gt;
&lt;li&gt;04:05 Ignored Directives in C# 14&lt;/li&gt;
&lt;li&gt;04:44 Using External Libraries in C# Scripts&lt;/li&gt;
&lt;li&gt;07:38 Conclusion&lt;/li&gt;
&lt;/ul&gt;</description>
      <link>https://endjin.com/what-we-think/talks/csharp-14-new-feature-script-directives</link>
      <guid isPermaLink="true">https://endjin.com/what-we-think/talks/csharp-14-new-feature-script-directives</guid>
      <pubDate>Fri, 28 Nov 2025 06:30:00 GMT</pubDate>
      <category>.NET 10</category>
      <category>C# 14</category>
      <category>dotnet</category>
      <category>Script Directives</category>
      <category>Open Source</category>
      <category>.NET Foundation</category>
      <category>Talk</category>
      <enclosure length="0" type="image/jpeg" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/csharp-14-script-directives.jpg" />
      <dc:creator>Ian Griffiths</dc:creator>
      <content:encoded><![CDATA[<p>C# 14 introduces new directives that transform C# into a true scripting language. In this video, Ian Griffiths explains how the .NET 10 SDK now lets you run a single C# source file directly—no project file required.</p>
<p>What you'll learn:</p>
<ul>
<li>How to run C# files directly with <code>dotnet run</code></li>
<li>The shebang (<code>#!</code>) directive for Unix/Linux script execution</li>
<li>The new <code>#:</code> directive for adding NuGet packages and build properties</li>
<li>How these features build on the low-ceremony entry points introduced in C# 9</li>
</ul>
<p>Contents:</p>
<ul>
<li>00:00 Introduction to C# 14 Scripting Capabilities</li>
<li>00:10 Running C# Source Files with .NET 10 SDK</li>
<li>00:32 Simplifying C# Program Structure</li>
<li>01:47 Shebang Syntax for Unix Systems</li>
<li>04:05 Ignored Directives in C# 14</li>
<li>04:44 Using External Libraries in C# Scripts</li>
<li>07:38 Conclusion</li>
</ul>
<p><a href="https://www.youtube.com/watch?v=WinOQSYxda8"><img src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/csharp-14-script-directives.jpg"></a></p><p>C# 14 has added new directives that enable C# to be used as a scripting language, starting with the .NET 10 SDK. The .NET run command now supports being run against a single C# source file, so you no longer need a project file. In this folder, I have just a single C# source file. There's no solution file and no .csproj, just this code. I can supply the file name to the .NET run command, which compiles and runs it. This is a natural progression from the C# 9 feature that enabled us to write the program entry point in this simple way. So whereas once we used to have to declare a containing method and type, now we can omit all of that and write just the statements that run when the program starts, and the compiler fills in the rest.</p>
<p>The basic principle was that we shouldn't have to write a load of boilerplate if the compiler can generate suitable defaults. And now in .NET 10, that same idea extends to the project file. If that was only going to contain the defaults, why not get rid of it completely? So now, for the first time, a complete and runnable C# program can live in a single source file.</p>
<p>So although C# continues to be a compiled language, these changes enable us to use it much more like a normal scripting language. The combination of the feature added back in C# 9 that let us write these low-ceremony entry points, in conjunction with the new .NET 10 SDK support for running a C# source file without needing a project file, enables us for the first time to put an entire C# program into a single runnable source file with no supporting files required. However, for this to be useful, we need some more things. On Unix operating systems, the convention is that a script file begins with what's sometimes known as the shebang syntax. So the idea here is that the very first line of the script will begin with a hash character followed by an exclamation mark, and the operating system detects that well-known starting sequence. It goes, "Ah, you're using a shebang. Right, the rest of that line is going to tell me which program I need to launch to execute your script successfully." So you see this in most shell scripts. You also see it when people are using Python files as scripts, and we need to do the same thing in C#.</p>
<p>I am running in a container here so that I can use Linux, which recognizes the hashbang convention. This particular container has the .NET SDK installed, and you can see this hello.cs file has execute permission set. In the file, we can see that it begins with a shebang line, and that is enough for me to be able to run this.</p>
<p>When I execute this file, the operating system sees that it begins with the hash and exclamation mark, so it knows that it has to run the command that follows, passing in the file path as an argument. So the effect is that this single C# source file becomes a runnable program. Now you do need the .NET SDK to be installed for this to work because the .NET run command has to compile the file to be able to run it, so this won't work if you've installed only the .NET runtime. And this is mostly an SDK feature. It is the .NET run command that's doing all the work here. The C# compiler just ignores that hashbang directive, and if you go and find the language specification for this new feature, its title is "ignored directives." So as far as the compiler's concerned, these things aren't really any different from a comment, but whereas a comment is designed to be read by other developers, the audience for this directive is the operating system. So it's there because Unix-style operating systems recognize this as a standard start to a script file, and they know what to do with it.</p>
<p>Now, C# 14 also defines another kind of ignored directive. You can also put lines that begin with a <code>#:</code>. Now again, the compiler just does nothing with these—it treats them in much the same way as it does the <code>#!</code> syntax. But these are intended for a different audience. So rather than being directed at the operating system, the <code>#:</code> lines are there for the benefit of the .NET run command. And these enable you to control aspects of how the source file is built that might otherwise have required you to add a project file.</p>
<p>For example, suppose we want to write a C# script that uses external libraries. I've got a script here that discovers the latest available version of a particular NuGet package. Specifically, it's looking at the latest version of System.Reactive, the main component of the Reactive Extensions for .NET, which my employer Endjin currently maintains, by the way. So to do this, my script uses the NuGet client SDK. Now that is not built into the .NET runtime. The NuGet client SDK is itself distributed via NuGet.</p>
<p>So to use it in my C# code, normally I'd expect to add a package reference in my .csproj file, but this is a script and the whole point is that it's self-contained. There is no .csproj file, so I need some way to tell the .NET run command that my code needs to use a NuGet package. And you can see that the second line of this script is a <code>#:</code> directive, and the dotnet run command searches for these in the file, and it expects the colon to be followed by text indicating what we're asking it to do. For this line, the word "package" tells the tool that this code uses a particular NuGet package, specifically the NuGet package that provides the NuGet client SDK that this code uses. Notice there's a second <code>#:</code> directive, this time with the text "property." This lets me set build properties. It turns out that the NuGet client SDK relies on reflection-based JSON serialization, a feature that's disabled by default for scripts, which would cause this program to fail with an exception.</p>
<p>But this directive tells the dotnet run command that when it builds our script into executable code, it should act as though I had a .csproj file with a property group setting this property to true. That enables the feature that the NuGet client library requires, and so the script just works.</p>
<p>So from a C# perspective, this is a really simple feature. It's just two new directive types, both of which the compiler completely ignores. This works and it's useful because the .NET SDK is now able to build and run source files directly. So the two directives: the first one is for the operating system's benefit—it enables us to put a <code>#!</code> mark on the first line of code, and Unix-like operating systems recognize that and it enables them to go and find the dotnet run command in order to execute the script. And then the second directive type enables us to provide instructions to the .NET run tool that might otherwise have required us to add a project file.</p>
<p>My name's Ian Griffiths. Thanks for listening.</p>]]></content:encoded>
    </item>
    <item>
      <title>Adventures in Least Privilege: When an owner isn't an owner</title>
      <description>A troubleshooting journey through Microsoft Entra ID that reveals the subtle but critical distinction between App Registration ownership and Service Principal ownership - and why it matters for least-privilege automation.</description>
      <link>https://endjin.com/blog/adventures-in-least-privilege-when-entra-owner-is-not</link>
      <guid isPermaLink="true">https://endjin.com/blog/adventures-in-least-privilege-when-entra-owner-is-not</guid>
      <pubDate>Thu, 27 Nov 2025 06:30:00 GMT</pubDate>
      <category>Automation</category>
      <category>CI/CD</category>
      <category>DevOps</category>
      <category>Entra ID</category>
      <category>Infrastructure as Code</category>
      <category>Least Privilege</category>
      <category>Security</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/11/adventures-in-least-privilege-when-entra-owner-is-not.png" />
      <dc:creator>James Dawson</dc:creator>
      <content:encoded><![CDATA[<p>There are often situations where we need to automate the configuration of Microsoft Entra ID resources as part of our infrastructure deployments. When doing so with a least-privilege mindset, we carefully select the minimum permissions required for our automation to succeed. Or at least that's the theory and as I discovered recently, the relationship between App Registrations and Service Principals has a subtle gotcha that can break even the most carefully planned least-privilege automation.</p>
<p>Let me walk you through a troubleshooting journey that taught me more about some of Entra ID's behind-the-scenes activities than I bargained for.</p>
<h2 id="the-setup">The Setup</h2>
<p>Picture this: you're deploying a <a href="https://endjin.com/what-we-think/talks/a-brief-introduction-to-streamlit-development">Streamlit</a> application on Azure Container Apps with Entra ID-based authentication and authorisation. The entire deployment is automated using Infrastructure-as-Code, including the Entra ID configuration itself. To keep things secure, you're using a user-assigned managed identity with carefully selected Microsoft Graph permissions:</p>
<ul>
<li><strong>Application.ReadWrite.OwnedBy</strong> - Create apps (&amp; service principals) and manage those we create</li>
<li><strong>Directory.Read.All</strong> - Read directory information for lookups</li>
<li><strong>Group.Create</strong> - Create security groups; also enables the option of setting ourselves as owners (but only at creation time!)</li>
</ul>
<p>These permissions are utilised through PowerShell ARM Deployment Scripts, and everything seems perfectly scoped. The <code>Application.ReadWrite.OwnedBy</code> permission is particularly elegant as it allows our managed identity to create App Registrations and automatically become their owner, without requiring the overly broad <code>Application.ReadWrite.All</code> permission.</p>
<p>The deployment script creates the App Registration, defines the necessary app roles, and configures the authentication settings. So far, so good.</p>
<div class="aside"><p><em><strong>NOTE</strong>: In case you're wondering why we're not using the <a href="https://learn.microsoft.com/en-us/community/content/microsoft-graph-bicep-extension">Bicep Graph Extension</a>, there are some subtleties in its current behaviour that are incompatible with a least-privilege mindset; for example, this <a href="https://github.com/microsoftgraph/msgraph-bicep-types/issues/233">GitHub issue</a>.</em></p>
</div>
<h2 id="the-problem">The Problem</h2>
<p>Then comes the moment to assign groups to the app roles exposed by our newly created app registration, enabling user authorisation within the Streamlit app. This is where things went wrong.</p>
<pre><code>Error: Insufficient privileges to complete the operation
</code></pre>
<p>Wait, what? We own the App Registration. We can see our managed identity listed as an owner in the portal. We should have full control over this resource. Time to investigate.</p>
<h2 id="down-the-missing-permission-rabbit-hole">Down the 'Missing Permission' Rabbit Hole</h2>
<p>All good troubleshooting sessions must send you down a rabbit hole at some point!</p>
<p>The first instinct is to check whether we need an additional Microsoft Graph permission. After all, we're trying to perform an app role assignment operation. Perhaps we need <code>AppRoleAssignment.ReadWrite.All</code>?</p>
<p>But here's the problem: <code>AppRoleAssignment.ReadWrite.All</code> is what Microsoft classifies as a "Tier 0" permission; one of the highest privilege levels in the entire tenant. According to the <a href="https://learn.microsoft.com/en-us/graph/permissions-reference">Microsoft Graph permissions reference</a>, this permission allows an application to:</p>
<div class="aside"><p>Grant additional privileges to itself, other applications, or any user</p>
</div>
<p>That's essentially the keys to the kingdom. An application with this permission can escalate to ANY permission in the tenant by assigning itself app roles on Microsoft Graph or other resources. This is precisely the kind of broad privilege we're trying to avoid.</p>
<p>Surely there must be a least-privilege variant, similar to how <code>Application.ReadWrite.OwnedBy</code> exists for application management?</p>
<p>There is no <code>AppRoleAssignment.ReadWrite.OwnedBy</code> permission (as we'll see, it's not necessary). Microsoft Graph API permissions are tenant-wide and cannot be scoped down to specific applications. This is a deliberate security design decision - the ability to assign arbitrary app roles is simply too powerful to scope safely.</p>
<p>For a security-conscious deployment following least-privilege principles, granting <code>AppRoleAssignment.ReadWrite.All</code> just to assign roles to our own application is unacceptable. There must be another way.</p>
<h2 id="the-first-clue">The First Clue</h2>
<p>While checking the App Registration in the portal, something catches my eye. Under Enterprise Applications, there's a Service Principal for our application. But curiously our deployment script does not include creating a Service Principal, so where did it come from?</p>
<p>Let's check the owners of this Service Principal. And there it is, our managed identity is NOT listed as an owner. Despite owning the App Registration, we don't own the corresponding Service Principal.</p>
<p>This is the smoking gun.</p>
<h2 id="applications-service-principals-two-objects-two-ownership-models">Applications &amp; Service Principals: Two Objects, Two Ownership Models</h2>
<p>Let's step back and understand what's really happening here. Microsoft Entra ID has a concept that's easy to overlook if you primarily work through the Azure Portal: <strong>Application objects and Service Principal objects are separate entities with independent ownership</strong>.</p>
<p>As explained in Microsoft's <a href="https://learn.microsoft.com/en-us/entra/identity-platform/app-objects-and-service-principals">App objects and service principals documentation</a>:</p>
<p><strong>Application Object (App Registration)</strong>:</p>
<ul>
<li>The globally unique definition of your application</li>
<li>Lives in your "home" tenant</li>
<li>Acts as a template or blueprint</li>
</ul>
<p><strong>Service Principal Object</strong>:</p>
<ul>
<li>The local representation of an application in a specific tenant</li>
<li>A concrete instance derived from the application object</li>
<li>Required for the application to actually authenticate users or access resources as itself</li>
</ul>
<p>One Application object can have many Service Principals (one per tenant where it's used in multi-tenant scenarios), but in single-tenant applications, there's typically one Service Principal in your home tenant.</p>
<h3 id="the-portals-helpful-deception">The Portal's Helpful Deception</h3>
<p>When you register an application through the Azure Portal, both the Application object AND its Service Principal are created simultaneously, and you automatically become owner of both. This convenience feature hides the fact that these are separate operations.</p>
<h3 id="the-apis-honest-truth">The API's Honest Truth</h3>
<p>When using the <a href="https://learn.microsoft.com/en-us/graph/overview">Microsoft Graph API</a> (which is what our ARM Deployment Script does under the hood) creating an Application object and creating a Service Principal are <strong>separate, explicit operations</strong>.</p>
<p>When you create an App Registration using the Graph API with <code>Application.ReadWrite.OwnedBy</code> permission, you automatically become owner of the Application object. However, the Service Principal must be created separately. If you don't explicitly create it, certain operations (like first consent or first app role assignment) will automatically create one for you.</p>
<p><strong>And here's the gotcha</strong>: when a Service Principal is automatically created, it does NOT inherit ownership from the Application object or the user that triggered the implicit creation.</p>
<div class="aside"><p>This is documented behaviour in the <a href="https://learn.microsoft.com/en-us/graph/api/serviceprincipal-post-serviceprincipals">API documentation</a>, though it's easy to miss the implications if you're not specifically looking for them.</p>
<p>In our case, the web app hadn't yet been accessed, so it was the 'first app role assignment' that triggered it; but it's interesting to learn that there are two scenarios for this implicit creation.</p>
</div>
<h3 id="why-this-design">Why This Design?</h3>
<p>This isn't a bug, it's a deliberate security boundary. Service Principals represent actual runtime identities in your tenant. They're what authenticate, what hold credentials, and what get assigned permissions; therefore ownership of them has significant security implications.</p>
<p>The principle here is <strong>explicit intent</strong>. The API requires you to explicitly state "I want a Service Principal for this Application" to prevent accidental sprawl of identities and ensuring proper ownership assignment.</p>
<h2 id="the-solution-embrace-explicit-creation">The Solution: Embrace Explicit Creation</h2>
<p>Once we understand the problem, the solution becomes clear. Our ARM Deployment Script needs to explicitly create the Service Principal, not rely on automatic creation.</p>
<p>The approach is straightforward:</p>
<ol>
<li>Create the App Registration (we automatically become owner)</li>
<li>Explicitly create the related Service Principal (we automatically become owner)</li>
<li>Now we can perform app role assignments because we own both objects</li>
</ol>
<p>The beauty of this approach is that it works perfectly with <code>Application.ReadWrite.OwnedBy</code>. According to the <a href="https://learn.microsoft.com/en-us/graph/api/serviceprincipal-post-serviceprincipals#permissions">permissions documentation</a>, creating a Service Principal requires either:</p>
<ul>
<li><code>Application.ReadWrite.OwnedBy</code> (least privileged)</li>
<li><code>Application.ReadWrite.All</code></li>
</ul>
<p>We already have the least-privilege permission we need.</p>
<h3 id="cleaning-up-the-orphaned-service-principal">Cleaning Up the Orphaned Service Principal</h3>
<p>Before implementing the fix, we need to delete the automatically created Service Principal that our managed identity doesn't own or manually add it as an owner. Since we can't manage it with our current permissions (we don't own it), this requires either:</p>
<ul>
<li>Using a Global Administrator account</li>
<li>Using the <code>Application.ReadWrite.All</code> permission temporarily</li>
<li>Using an account that has the appropriate Entra ID role (Application Administrator or higher)</li>
</ul>
<p>Once cleaned up, update our deployment script to explicitly create the Service Principal immediately after creating the App Registration. When we run the updated script, both objects will be created with the managed identity as owner, and app role assignments will work as expected.</p>
<h2 id="lessons-learned-principles-for-least-privilege-entra-id-automation">Lessons Learned: Principles for Least-Privilege Entra ID Automation</h2>
<p>This troubleshooting journey reinforced several important principles for anyone automating Entra ID configuration.</p>
<h3 id="understand-the-dual-object-model">1. Understand the Dual-Object Model</h3>
<p>App Registrations and Service Principals are distinct objects. The portal's convenience features can mask this reality. When working with APIs or automation, treat them as separate entities that both require explicit management.</p>
<p><strong>Ref</strong>: <a href="https://learn.microsoft.com/en-us/entra/identity-platform/app-objects-and-service-principals">App objects and service principals</a></p>
<h3 id="application.readwrite.ownedby-is-powerful-but-has-boundaries">2. Application.ReadWrite.OwnedBy is Powerful but Has Boundaries</h3>
<p>The <code>Application.ReadWrite.OwnedBy</code> permission is excellent for least-privilege automation. It allows you to:</p>
<ul>
<li>Create applications (you automatically become owner)</li>
<li>Create service principals (you automatically become owner)</li>
<li>Fully manage resources you own</li>
<li>List all applications and service principals in the tenant</li>
</ul>
<p>However, it does NOT allow you to:</p>
<ul>
<li>Manage applications or service principals you don't own</li>
<li>Be an owner of Service Principals created by automatic processes</li>
</ul>
<p><strong>Ref</strong>: <a href="https://learn.microsoft.com/en-us/graph/permissions-reference">Microsoft Graph permissions reference</a></p>
<h3 id="explicit-is-better-than-implicit">3. Explicit is Better than Implicit</h3>
<p>Following Python's zen, explicit is better than implicit. In infrastructure automation, this is doubly true. Be aware of when automatic object creation can happen, so you can avoid relying on it (unwittingly or otherwise) and create them explicitly in your deployment scripts. This ensures:</p>
<ul>
<li>Predictable ownership assignment</li>
<li>Clear audit trails of what was created when</li>
<li>Proper permissions from the start</li>
<li>No orphaned resources</li>
</ul>
<h3 id="approleassignment.readwrite.all-is-too-privileged">4. AppRoleAssignment.ReadWrite.All is Too Privileged</h3>
<p>While it might seem like an easy solution to app role assignment problems, <code>AppRoleAssignment.ReadWrite.All</code> is classified as Tier 0 privilege. It can be used to escalate to any permission in the tenant. Reserve this permission only for scenarios where it's absolutely necessary, and never use it when a more scoped approach exists.</p>
<h3 id="the-portal-and-api-have-different-behaviours">5. The Portal and API Have Different Behaviours</h3>
<p>The Azure Portal provides convenience features that streamline common workflows. This is excellent for interactive use, but automation requires understanding the underlying API behaviour. What happens automatically in the portal often requires explicit steps in API-based automation.</p>
<h3 id="consider-owners-in-your-automation">6. Consider Owners in Your Automation</h3>
<p>When creating Applications &amp; Service Principals, you can add additional owners using the <a href="https://learn.microsoft.com/en-us/graph/api/application-post-owners">Add application owner</a> &amp; <a href="https://learn.microsoft.com/en-us/graph/api/serviceprincipal-post-owners">Add service principal owner</a> APIs respectively. This requires <code>Application.ReadWrite.OwnedBy</code>, although <code>Directory.Read.All</code> is often useful if you need to look up identities by name (as the APIs require the 'ObjectId', also known as the 'PrincipalId').</p>
<p>Consider whether your automation needs multiple owners for lifecycle management.</p>
<h2 id="closing-thoughts">Closing Thoughts</h2>
<p>The beauty of Infrastructure-as-Code and automation is that once you understand the correct pattern, you can codify it and never encounter this problem again. The pain of troubleshooting becomes the foundation of better practices.</p>
<p>Microsoft's security model for Entra ID is sophisticated and well-thought-out. The separation between Application objects and Service Principal objects provides important security boundaries. The challenge is that this sophistication isn't always obvious, especially when the Azure Portal's convenience features smooth over the rough edges.</p>
<p>For those of us building automated deployments with least-privilege principles, understanding these nuances isn't optional — it's essential. The difference between <code>Application.ReadWrite.OwnedBy</code> and <code>Application.ReadWrite.All</code> might seem subtle, but it represents the difference between scoped, secure automation and unnecessarily broad permissions.</p>
<p>I hope this troubleshooting journey saves you the hours I spent tracking down this particular gotcha.</p>
<p>Have you encountered similar gotchas in Azure or Entra ID automation? Leave a comment below, or ping me via Bluesky <a href="https://bsky.app/profile/jdawson.bsky.social">@jdawson.bsky.social</a>.</p>]]></content:encoded>
    </item>
    <item>
      <title>Ix.NET v7.0: .NET 10 and LINQ for IAsyncEnumerable&lt;T&gt;</title>
      <description>Ix.NET 7.0.0 is now available. Because .NET 10.0 now includes LINQ for IAsyncEnumerable, Ix.NET's System.Linq.Async has had to step back. This post explains what has changed and why.</description>
      <link>https://endjin.com/blog/ix-v7-dotnet-10-linq-iasyncenumerable</link>
      <guid isPermaLink="true">https://endjin.com/blog/ix-v7-dotnet-10-linq-iasyncenumerable</guid>
      <pubDate>Wed, 26 Nov 2025 05:30:00 GMT</pubDate>
      <category>Ix</category>
      <category>Ix.NET</category>
      <category>.NET 10</category>
      <category>IAsyncEnumerable&lt;T&gt;</category>
      <category>Rx</category>
      <category>Rx.NET</category>
      <category>Reactive Extensions</category>
      <category>Reactive</category>
      <category>System.Reactive</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>dotnet</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/11/ix-v7-dotnet-10-linq-iasyncenumerable.png" />
      <dc:creator>Ian Griffiths</dc:creator>
      <content:encoded><![CDATA[<p>We've just released a new version of the <a href="https://github.com/dotnet/reactive">Interactive Extensions for .NET (Ix.NET)</a>. Now that .NET 10.0 offers built-in support for LINQ to <code>IAsyncEnumerable&lt;T&gt;</code>, it's time for Ix.NET's <a href="https://www.nuget.org/packages/System.Linq.Async"><code>System.Linq.Async</code></a> to step back.</p>
<h2 id="why-you-might-need-to-upgrade">Why you might need to upgrade</h2>
<p>If you've been seeing errors of this kind since .NET 10 shipped:</p>
<p><code>error CS0121: The call is ambiguous between the following methods or properties: 'System.Linq.AsyncEnumerable.Select&lt;TSource, TResult&gt;(System.Collections.Generic.IAsyncEnumerable&lt;TSource&gt;, System.Func&lt;TSource, TResult&gt;)' and 'System.Linq.AsyncEnumerable.Select&lt;TSource, TResult&gt;(System.Collections.Generic.IAsyncEnumerable&lt;TSource&gt;, System.Func&lt;TSource, TResult&gt;)'</code></p>
<p>you may need to upgrade to v7 of Ix.NET's <a href="https://www.nuget.org/packages/System.Linq.Async/7.0.0"><code>System.Linq.Async</code></a> package. (In the long run you will want to stop using it entirely, and use the .NET runtime library <a href="https://www.nuget.org/packages/System.Linq.AsyncEnumerable"><code>System.Linq.AsyncEnumerable</code></a> package instead, but if you've ended up seeing these errors because of an indirect dependency, you might not be able to remove the reference just yet, in which case you'll need to upgrade it instead.)</p>
<p>In most cases, that will solve the problem. It's possible you'll also need to add a reference to <code>System.Interactive.Async</code> v7 (or upgrade an existing reference to that version). There are also some more complex scenarios. This post explains what has changed and why.</p>
<h2 id="net-10.0-and-linq-to-iasyncenumerablet">.NET 10.0 and LINQ to <code>IAsyncEnumerable&lt;T&gt;</code></h2>
<p>The main reason for this new Ix.NET release is that .NET 10.0 now implements a feature that used to be part of Ix.NET: LINQ for <code>IAsyncEnumerable&lt;T&gt;</code>.</p>
<p>For years, if you wanted to use the standard LINQ operators with <code>IAsyncEnumerable&lt;T&gt;</code>, you had to use the <code>System.Linq.Async</code> library. Despite its name, this was not part of the .NET runtime class libraries, and was not maintained by Microsoft. That library was originally produced by the Rx.NET team as part of the Ix.NET libraries. This occurred after Microsoft stopped work on Rx.NET, so <code>System.Linq.Async</code> was always a community-maintained library. (See the <a href="https://endjin.com/blog/ix-v7-dotnet-10-linq-iasyncenumerable#history">History</a> section for the reasons behind this.) But .NET 10.0 now supplies the functionality that <code>System.Linq.Async</code> was originally written to provide.</p>
<p>The .NET team did consider just taking over ownership of <code>System.Linq.Async</code>, but decided instead to reimplement LINQ for <code>IAsyncEnumerable&lt;T&gt;</code> from scratch. This was partly motivated by the fact that the old <code>System.Linq.Async</code> package predates some library design guidelines, and made some naming choices that do not align with current practice.</p>
<p>This new implementation lives in an assembly called <code>System.Linq.AsyncEnumerable</code>, which is built into .NET 10. It is also available for use on older verions of .NET (including .NET Framework) through the <a href="https://www.nuget.org/packages/System.Linq.AsyncEnumerable/"><code>System.Linq.AsyncEnumerable</code></a> NuGet package. This provides a complete implementation of LINQ for <code>IAsyncEnumerable&lt;T&gt;</code>.</p>
<h2 id="what-this-means-for-developers">What this means for developers</h2>
<p>Anyone writing new code that targets .NET 10 can use the standard LINQ operators on any <code>IAsyncEnumerable&lt;T&gt;</code> without needing to add any NuGet packages. But where it gets a little more tricky is if either:</p>
<ul>
<li>you were already using the old <code>System.Linq.Async</code> and have upgraded to .NET 10.0</li>
<li>you end up with a transitive dependency on the old <code>System.Linq.Async</code></li>
</ul>
<p>That second one will be quite common because <code>System.Linq.Async</code> is a widely used package. You might be using it without ever having asked for it.</p>
<p>(Note that it's also possible to hit problems without even upgrading to .NET 10.0. The problems that occur in these two scenarios aren't really to do with being on the .NET 10.0 runtime: they happen because <code>System.Linq.Async</code> v6 clashes with <code>System.Linq.AsyncEnumerable</code>. .NET 10.0 includes <code>System.Linq.AsyncEnumerable</code> 'in the box' so an upgrade to .NET 10.0 is likely to be the most common reason for encountering this clash. But it can also happen on .NET Framework or .NET 8.0 or .NET 9.0, because you can use <code>System.Linq.AsyncEnumerable</code> on those runtimes. The new <code>System.Linq.AsyncEnumerable</code> is built into .NET 10.0, but it's available for use via NuGet on those older platforms.)</p>
<p>The problem here is that you can end up with two implementations of LINQ for <code>IAsyncEnumerable&lt;T&gt;</code>: the old <code>System.Linq.Async</code> (Ix.NET) and the new <code>System.Linq.AsyncEnumerable</code> (.NET runtime libraries). When two implementations of every standard LINQ operator are available for <code>IAsyncEnumerable&lt;T&gt;</code>, the compiler emits <code>error CS0121: The call is ambiguous</code> messages any time you try to use them.</p>
<p>In most cases the fix is simple: upgrade to <code>System.Linq.Async</code> v7. (If you were using it directly, this just means upgrading your existing package reference to the latest version. If you have ended up with an indirect reference through some other package you'll need to add a new reference to the latest version of <code>System.Linq.Async</code>.) In some cases, for reasons described later, you might need to add a reference (or upgrade an existing reference) to <code>System.Interactive.Async</code> v7.</p>
<h2 id="system.linq.async-will-be-deprecated"><code>System.Linq.Async</code> will be deprecated</h2>
<p>V7 of <code>System.Linq.Async</code> provides a quick fix for the compilation errors that developers may encounter in .NET 10, but the longer term solution is for everyone to stop using <code>System.Linq.Async</code>. Its only purpose was to provide LINQ for <code>IAsyncEnumerable&lt;T&gt;</code>, and now that the .NET runtime libraries supply this through <code>System.Linq.AsyncEnumerable</code>, there is no longer any reason for Ix's <code>System.Linq.Async</code> to exist.</p>
<p>So Ix's <code>System.Linq.Async</code> is now a legacy component that exists purely for backwards compatibility reasons. If you're writing an application that has ended up depending on <code>System.Linq.Async</code> you might not be able to get rid of that dependency—you'll have to wait until the authors of the libraries that depend on it stop using it. But if you have only a direct dependency on <code>System.Linq.Async</code>, you should stop using it, and should switch to <code>System.Linq.AsyncEnumerable</code> instead. (You may also need to add a reference to <code>System.Interactive.Async</code> for reasons described later.)</p>
<p>We will be deprecating the <code>System.Linq.Async</code> package to encourage people to move off it.</p>
<p>However, if you do this, you may discover that the .NET runtime's <code>System.Linq.AsyncEnumerable</code> is not an <em>exact</em> replacement. There are two issues:</p>
<ul>
<li>Some methods have been renamed because naming conventions changed since Ix.NET first provided <code>IAsyncEnumerable&lt;T&gt;</code> LINQ support</li>
<li>The new <code>System.Linq.AsyncEnumerable</code> has omitted some of the functionality that Ix supplied</li>
</ul>
<p>An example of the first kind of issue occurs with operators that take callbacks, such as <code>Where</code>. When filtering an <code>IAsyncEnumerable&lt;T&gt;</code>, you might want to use a normal <code>Func&lt;T, bool&gt;</code> just like you would with <code>IEnumerable&lt;T&gt;</code>, but since you're in an asynchronous world, you might actually want to provide an <code>async</code> callback, and you might want that to support cancellation. To support that, Ix's <code>System.Linq.Async</code> offered not only <code>Where</code>, but also <code>WhereAwait</code> and <code>WhereAwaitWithCancellation</code>.</p>
<p>This same functionality exists in .NET's <code>System.Linq.AsyncEnumerable</code>, but there are two important changes:</p>
<ul>
<li>the async callback overloads have the same name as the normal ones (e.g., instead of <code>WhereAwait</code> and <code>WhereAwaitWithCancellation</code>, we now have just overloads of <code>Where</code>)</li>
<li>the async callback overloads <em>require</em> the callback to accept a <code>CancellationToken</code> (which the callback is free to ignore)</li>
</ul>
<p>If you're using these async callback operator forms today and you upgrade to v7 of <code>System.Linq.Async</code>, you will see warnings of this kind:</p>
<p><code>warning CS0618: 'AsyncEnumerable.WhereAwait&lt;TSource&gt;(IAsyncEnumerable&lt;TSource&gt;, Func&lt;TSource, ValueTask&lt;bool&gt;&gt;)' is obsolete: 'Use Where. IAsyncEnumerable LINQ is now in System.Linq.AsyncEnumerable, and the WhereAwait functionality now exists as overloads of Where. You will need to modify your callback to take an additional CancellationToken argument.'</code></p>
<p>We continue to provide these old methods, but we provide a deprecation warning to encourage you to move onto the new equivalents provided by the .NET runtime libraries.</p>
<p><strong>Note</strong>: if you're using any of the old methods with <code>Await</code> in their name, you will need to do more than just using the new method name, because the new .NET runtime library implementations <em>require</em> your callback to take a <code>CancellationToken</code>. If you don't add this extra parameter to your callbacks, you will get errors of this form:</p>
<p><code>error CS4010: Cannot convert async lambda expression to delegate type 'Func&lt;int, bool&gt;'. An async lambda expression may return void, Task or Task&lt;T&gt;, none of which are convertible to 'Func&lt;int, bool&gt;'.</code></p>
<p>This is not a very helpful message because it doesn't explain what you need to do. (Note that the deprecation method <em>does</em> tell you what you need to do, but if you didn't read that all the way to the end, you will have missed the part that saves you from this error.) If you had this:</p>
<pre><code>IAsyncEnumerable&lt;int&gt; evens = GenerateNumbersAsync(10)
    .WhereAwait(async x =&gt; x % 2 == 0);
</code></pre>
<p>and after reading about two thirds of the  deprecation warning you changed it to this:</p>
<pre><code class="language-cs">IAsyncEnumerable&lt;int&gt; evens = GenerateNumbersAsync(10)
    .Where(async x =&gt; x % 2 == 0); // CS4010 error on this line
</code></pre>
<p>you'll get that error above. You need to modify the lambda to accept an additional argument:</p>
<pre><code class="language-cs">IAsyncEnumerable&lt;int&gt; evens = GenerateNumbersAsync(10)
    .Where(async (x, _) =&gt; x % 2 == 0);
</code></pre>
<p>The change here is that instead of the single <code>x</code> parameter, we now have a parameter list: <code>(x, _)</code>. That underscore indicates that we don't actually want to use our second argument. (It's a <a href="https://endjin.com/blog/csharp-lambda-discards">discard</a>.) That's OK, but we still have to accept the argument, because the new <code>System.Linq.AsyncEnumerable</code> (.NET runtime) implementation of <code>Where</code> does not support the single-argument callbacks that <code>System.Linq.Async</code> (Ix) did. This is not a change in fundamental capability: you're not obliged to do anything with that cancellation token. But it does mean you need to change more than just the name of the method you're calling.</p>
<p>There's a further trap for <code>Select</code>. If you had code like this:</p>
<pre><code class="language-cs">xs.Select(async v =&gt; ...)
</code></pre>
<p>it's not enough to do this:</p>
<pre><code class="language-cs">xs.Select(async (v, _) =&gt; ...)
</code></pre>
<p>because the compiler can't tell whether you mean the overload that accepts a <code>Func&lt;TElement, CancellationToken, ValueTask&lt;Result&gt;&gt;</code>, or the overload that takes a callback which receives an extra parameter indicating the index of the value, which is of the form <code>Func&lt;TElement, int, TResult&gt;</code>. (The basic issue here is that in its standard form, <code>Select</code> can accept either 1- or 2-argument projection callbacks. This means the additional cancellable forms can create ambiguity.) So you need to make it clear which overload you mean by specifying the argument types. For example if <code>xs</code> is an <code>IAsyncEnumerable&lt;int&gt;</code> you can write this:</p>
<pre><code class="language-cs">xs.Select(async (int v, CancellationToken _) =&gt; ...)
</code></pre>
<p>Note that this ambiguity has nothing to do with the transition from Ix's <code>System.Linq.Async</code> to .NET's <code>System.Linq.AsyncEnumerable</code>. You can run into exactly this error if you create a brand new .NET 10 project and did not use Ix at all. (I don't know the history, but it's possible that the Rx team chose to use the unusual <code>Await</code> and <code>WithCancellation</code> naming conventions to avoid exactly this kind of ambiguity.)</p>
<p>Now you might have done all of this, and still find that if you attempt to remove your reference to the old <code>System.Linq.Async</code> NuGet package, your code no longer compiles. In which case, read on...</p>
<h2 id="relocated-functionality">Relocated functionality</h2>
<p>The new <code>System.Linq.AsyncEnumerable</code> (.NET runtime) library does not provide all of the functionality that <code>System.Linq.Async</code> (Ix) library did. This table describes the relevant extension methods for <code>IAsyncEnumerable&lt;T&gt;</code>:</p>
<table>
<thead>
<tr>
<th>Operator</th>
<th>Feature</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>AsAsyncEnumerable</code></td>
<td>Similar to <a href="https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.asenumerable?view=net-9.0"><code>Enumerable.AsEnumerable</code></a>—ensures only <code>IAsyncEnumerable&lt;T&gt;</code>-typed operations are available when a type might have other extension methods available e.g. due to implementing multiple interfaces</td>
</tr>
<tr>
<td><code>AverageAsync</code></td>
<td>Projection-based overloads (e.g., <code>xs.AverageAsync(p =&gt; p.Age)</code>)</td>
</tr>
<tr>
<td><code>SumAsync</code></td>
<td>Projection-based overloads (e.g., <code>xs.SumAsync(p =&gt; p.Mass)</code>)</td>
</tr>
<tr>
<td><code>ToObservable</code></td>
<td>Adapts an <code>IAsyncEnumerable&lt;T&gt;</code> to an Rx <code>IObservable&lt;T&gt;</code></td>
</tr>
</tbody>
</table>
<p>Since our goal is for people to stop using <code>System.Linq.Async</code>, we can't just leave these methods in there. So we have moved them into <code>System.Interactive.Async</code>.</p>
<div class="aside"><p>Historically, the split between <code>System.Linq.Async</code> and <code>System.Interactive.Async</code> was that the former contained 'standard' LINQ operators found on all LINQ providers, and the latter is the home for operators invented by the Rx team. Since the .NET Runtime team has decided that the operators listed above aren't standard, evidently they belong in <code>System.Interactive.Async</code>.</p>
</div>
<p><code>System.Linq.Async</code> v7 includes a transitive reference to <code>System.Interactive.Async</code>, so it should 'just work'. But we are deprecating <code>System.Linq.Async</code>, and when a project stops using that, it might be necessary to add in a reference to <code>System.Interactive.Async</code> so that these non-standard methods or overloads remain available.</p>
<p>Note that <code>System.Linq.Async</code> also defined a <code>ToEnumerable</code> method that adapts any <code>IAsyncEnumerable&lt;T&gt;</code> to <code>IEnumerable&lt;T&gt;</code>. This is a 'sync over async' operation, and those are usually a bad idea, and we believe it was a mistake for <code>System.Linq.Async</code> ever to have offered this. We have elected not to provide a new version of that. If you need this, we suggest you rethink your design. And if after that you really think you still need it, well, Ix.NET is open source, so you can always find the original implementation, but our view is that you will be better off not using it.</p>
<p>There are also some extension methods from other types to <code>IAsyncEnumerable&lt;T&gt;</code> that <code>System.Linq.AsyncEnumerable</code> did not duplicate:</p>
<table>
<thead>
<tr>
<th>Target</th>
<th>Method</th>
<th>Feature</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>IObservable&lt;T&gt;</code></td>
<td><code>ToAsyncEnumerable</code></td>
<td>Adapts an Rx source to an <code>IAsyncEnumerable&lt;T&gt;</code></td>
</tr>
<tr>
<td><code>Task&lt;T&gt;</code></td>
<td><code>ToAsyncEnumerable</code></td>
<td>Adapts a <code>Task&lt;T&gt;</code> to an <code>IAsyncEnumerable&lt;T&gt;</code></td>
</tr>
</tbody>
</table>
<p>Again, these are now available in <code>System.Interactive.Async</code>.</p>
<p>There is one non-extension static method formerly defined by <code>AsyncEnumerable</code>:</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Feature</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Create</code></td>
<td>Callback based sequence creation</td>
</tr>
</tbody>
</table>
<p>We've moved this into <code>System.Interactive.Async</code>, and unfortunately this is a case where we've had to make a breaking change where we can't help developers out with an <code>Obsolete</code> message. It is necessary for the <code>System.Linq.Async</code> package's reference assemblies <strong>not</strong> to define a public <code>AsyncEnumerable</code> type, because if they did, it would cause compiler errors in code that attempted to use static members directly. For example, if you wrote <code>AsyncEnumerable.Range(1, 10)</code>, then although this method is now available in .NET 10, if <code>System.Linq.Async</code> defined its own <code>AsyncEnumerable</code> it would cause this error:</p>
<p><code>error CS0433: The type 'AsyncEnumerable' exists in both 'System.Linq.Async, Version=7.0.0.0, Culture=neutral, PublicKeyToken=94bc3704cddfc263' and 'System.Linq.AsyncEnumerable, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'</code></p>
<p>If all the methods on <code>AsyncEnumerable</code> were extension methods this would matter less. (It would still be a potential problem because you are allowed to invoke extension methods using normal static method syntax.) C# doesn't care if two types with identical names both define extension methods: as long as the individual methods don't clash, there's no ambiguity problem. But if you ever refer to the defining class by name (i.e., you refer to the <code>AsyncEnumerable</code> class) then the existence of two definitions becomes a problem and you get that error.</p>
<p>So it is not possible for <code>System.Linq.Async</code>'s public API to include an <code>AsyncEnumerable</code> type. For the most part this isn't a problem: for extension methods we can move them to a different type. (We call this <code>AsyncEnumerableDeprecated</code>, because the only reason <code>System.Linq.Async</code>'s public API retains any of the methods that it used to define on <code>AsyncEnumerable</code> is to be able to provide <code>[Obsolete]</code> attributes telling you what to use instead, and for extension methods, those are equally effective even if we change the defining class name.) And most of the non-extension static methods we used to define are now available on the new <code>System.Linq.AsyncEnumerable</code> library's <code>AsyncEnumerable.</code></p>
<p>But this one method, <code>Create</code> is an unfortunate exception. There's really nothing we can do other than remove if from the public face of <code>System.Linq.Async</code>, and define its replacement in <code>AsyncEnumerableEx</code> in <code>System.Interactive.Async</code>. We can retain binary compatibility because the runtime assemblies in <code>System.Linq.Async</code> continue to define <code>AsyncEnumerable</code> exactly as before. But code that was calling <code>AsyncEnumerable.Create</code> before will now just get an error reporting that this method does not exist, and the developer will have to guess that they now need to use <code>AsyncEnumerableEx.Create</code>. Our hope is that because this method hasn't been very useful since C# added support for <code>IAsyncEnumerable&lt;T&gt;</code> iterator methods (<code>yield return</code> etc.) that not many people will be using it. The built in language support does the same thing only better.</p>
<p>The methods described so far in this section are ones that the .NET Runtime team did not consider to be 'standard' operators. Interestingly, there are some that Ix.NET didn't consider to be 'standard' that the .NET Runtime team <em>did</em>. The following methods were defined in <code>System.Interactive.Async</code> v6 because at the time they didn't align with standard operators (or at least, standard overloads) available on other LINQ implementations:</p>
<table>
<thead>
<tr>
<th>Operator</th>
<th>Feature</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Distinct</code></td>
<td>Projection-based overload now available in <code>System.Linq.AsyncEnumerable</code> as <code>DistinctBy</code></td>
</tr>
<tr>
<td><code>MaxAsync</code></td>
<td>Non-projecting overload previously considered <em>non-standard</em> is in <code>System.Linq.AsyncEnumerable</code></td>
</tr>
<tr>
<td><code>MaxByAsync</code></td>
<td>Non-standard max-with-ties feature that Ix.NET is renaming as <code>MaxWithTiesAsync</code>; <code>System.Linq.AsyncEnumerable</code> defines an operator with this name that has a different return type and different behaviour</td>
</tr>
<tr>
<td><code>MinAsync</code></td>
<td>Non-projecting overload previously considered <em>non-standard</em> is in <code>System.Linq.AsyncEnumerable</code></td>
</tr>
<tr>
<td><code>MinByAsync</code></td>
<td>Non-standard max-with-ties feature that Ix.NET is renaming as <code>MinWithTiesAsync</code>; <code>System.Linq.AsyncEnumerable</code> defines an operator with this name that has a different return type and different behaviour</td>
</tr>
</tbody>
</table>
<p>The <code>MaxByAsync</code> and <code>MinByAsync</code> members are problematic because back when Ix.NET introduced these (and their non-async counterparts, <code>MaxBy</code> and <code>MinBy</code>, in <code>System.Interactive</code>), not only were they non-standard operators, they worked differently from the <code>MaxBy</code> and <code>MinBy</code> eventually introduced in .NET 6.0. Back in Ix.NET 6.0, <code>System.Interactive</code> had to hide the <code>IEnumerable&lt;T&gt;</code> versions of these methods and introduce new <code>MaxByWithTies</code> and <code>MinByWithTies</code> operators to continue to make the functionality available. Unfortunately, <code>System.Interactive.Async</code> was not updated in the same way at that time, which is a pity because the old methods could have been retained at that time with <code>Obsolete</code> attributes, giving people time to move onto the new names. Since that didn't happen, we have no choice but to get out of the way of the new <code>MinByAsync</code> and <code>MaxByAsync</code> that .NET supplies without warning. Anyone with code that was using the existing methods of these names in <code>System.Interactive.Async</code> is going to get confusing error messages when they upgrade to .NET 10.0, and we can't easily supply guidance. (In theory we could write an analyzer to detect this, but it's complex, and remember we have no budget at all for this work. It's all eating into time we'd rather be spending working on Rx.NET.)</p>
<p>So if you have code like this:</p>
<pre><code class="language-cs">IList&lt;Person&gt; oldest = await people.MaxByAsync(p =&gt; p.Age);
IList&lt;Person&gt; youngest = await people.MinByAsync(p =&gt; p.Age);
</code></pre>
<p>that compiles on .NET 8.0, you'll find that it fails to compile on .NET 10.0 (or if you stay on .NET 8.0 but add a reference to <code>System.Linq.AsyncEnumerable</code>):</p>
<p><code>error CS0029: Cannot implicitly convert type 'Person' to 'System.Collections.Generic.IList&lt;Person&gt;'</code></p>
<p>Note that if you prefer to use <code>var</code>, you won't get an error with this code when upgrading:</p>
<pre><code class="language-cs">var oldest = await people.MaxByAsync(p =&gt; p.Age);
var youngest = await people.MinByAsync(p =&gt; p.Age);
</code></pre>
<p>but the library upgrade will change the type of these two variables. So instead of the compiler error occurring on the line where things went wrong, you'll likely get an error later on in the code when you attempt to use the relevant variable. (Or worse, you won't get a compiler error, but the meaning of your code will subtly change without you intending it to. But since this changes the variables' types from <code>IList&lt;Person&gt;</code> to <code>Person</code>, you will most likely get an error later on in the code.)</p>
<p>You'll need to change it to the following:</p>
<pre><code class="language-cs">IList&lt;Person&gt; oldest = await people.MaxByWithTiesAsync(p =&gt; p.Age);
IList&lt;Person&gt; youngest = await people.MinByWithTiesAsync(p =&gt; p.Age);
</code></pre>
<p>(The reason Ix.NET's returns a list here is that there might be more than one item that has the highest value. The .NET runtime's <code>MinBy</code> and <code>MaxBy</code> pick one arbitrary winner.)</p>
<h2 id="target-frameworks">Target frameworks</h2>
<p>The <code>System.Linq.Async</code>, and <code>System.Interactive.Async</code> packages now have an additional <code>net10.0</code> target framework. They don't technically need it, because the changes made necessary by .NET 10's addition of <code>System.Linq.AsyncEnumerable</code> are also required on older targets because the new <code>System.Linq.AsyncEnumerable</code> package can also be used on those older targets.</p>
<p>So this is purely a cosmetic move, intended to signal that these packages are .NET 10-aware.</p>
<p>Since v7 of Ix.NET is entirely about ensuring we work well with the new <code>System.Linq.AsyncEnumerable</code> package, nothing else has changed, and so the <code>System.Interactive</code>, and <code>System.Interactive.Providers</code> packages support the same TFMs as before, with nothing later than <code>net6.0</code>. They still work perfectly well on .NET 10.0.</p>
<p>We will update the TFMs so that no package has a .NET TFM lower than <code>net8.0</code> in a future release. (Most likely we will produce a v8 fairly soon that makes explicit that we no longer support .NET 6 or 7.) We just didn't want to conflate that with the fixes required to coexist with the new <code>System.Linq.AsyncEnumerable</code>.</p>
<h2 id="method-hiding-technical-details">Method hiding: technical details</h2>
<p>I've mentioned a few times that we have hidden some methods in <code>System.Linq.Async</code>, but I've not explained what that means.</p>
<p>We can't just remove methods from <code>System.Linq.Async</code>. If we did that we would break binary compatibility. Suppose you're using a library called <code>UsesOldIx</code> that was built against <code>System.Linq.Async</code> v6, and your application upgrades to v7 of <code>System.Linq.Async</code>. And suppose that the <code>UsesOldIx</code> library has <em>not</em> been updated. Your application will now be supplying <code>UseOldIx</code> with v7 of <code>System.Linq.Async</code> even though <code>UseOldIx</code> was built against v6. So <code>UseOldIx</code> doesn't know anything about the new <code>System.Linq.AsyncEnumerable</code>—it will expect all these LINQ to <code>IAsyncEnumerable&lt;T&gt;</code> methods still to reside in <code>System.Linq.Async</code>.</p>
<p>If we just removed methods completely, scenarios like this would cause the application to crash with a <code>MissingMethodException</code>. So we use a trick: we hide the method at build time, but continue to make it available at runtime.</p>
<p>We do this by supplying separate runtime and reference assemblies in the NuGet package. If you look inside the <code>System.Linq.Async</code> package you'll find that as well as the usual <code>lib</code> folder, there's also a <code>ref</code> folder. When both are present, the .NET build tools tell the compiler to look at the assemblies in the <code>ref</code> folder. This enables us to remove methods from the public API at build time—we omit them from the reference assembly—but to leave them present in the runtime assemblies (in the <code>lib</code> folder) so any code that was already built against an older version will still be able to access the hidden methods.</p>
<p><code>System.Interactive</code> was already using this in v6 to deal with the <code>Min</code>/<code>MaxBy</code> issues. As described above it effectively had to rename <code>MinBy</code> to <code>MinByWithTies</code> and <code>MaxBy</code> to <code>MaxByWithTies</code> because .NET 6 had added new methods with these names that did different things. So these had to be hidden but not entirely removed. So this trick is not new—Ix.NET has already been using it for a while, and now we're using it in <code>System.Linq.Async</code> too.</p>
<h2 id="history">History</h2>
<p>You might be wondering why the LINQ implementation for <code>IAsyncEnumerable&lt;T&gt;</code> was a community supported project in the first place.</p>
<p>In fact the code that ultimately ended up in the Ix.NET <code>System.Linq.Async</code> package did originate from Microsoft. More specifically, it was an invention of the Rx.NET team, back when that was part of Microsoft. So the reason <code>System.Linq.Async</code> is a community supported project is that Rx.NET itself became a community-supported project.</p>
<p>(For a highly detailed account of this history and related events, you can request a copy of the 'A Little History of Reaqtor' ebook from <a href="https://reaqtive.net/">https://reaqtive.net/</a>)</p>
<p>So the real question is: why did the Rx.NET team end up implementing LINQ to <code>IAsyncEnumerable&lt;T&gt;</code>? And the answer is that <code>IAsyncEnumerable&lt;T&gt;</code> itself was also originally invented by the Rx.NET team.</p>
<p>Rx.NET team emerged from a group at Microsoft that formed back when cloud computing was just getting established, who were asked to investigate what the cloud would mean for ordinary developers, and how software development might need to change to be able to take advantage of cloud-native architectures. One of the main researchers in this group was <a href="https://en.wikipedia.org/wiki/Erik_Meijer_%28computer_scientist%29">Erik Mejier</a>. He had also been a significant contributor to the design of LINQ. He had always envisaged LINQ as being more general than merely enabling database integration in C# and VB.NET, and Rx.NET was one realisation of his vision. When <code>async</code>/<code>await</code> were being developed, it would have been a natural step for him to consider what that might mean for LINQ, and so it is perhaps unsurprising that his Rx.NET team produced the original <code>IAsyncEnumerable&lt;T&gt;</code> interface definition.</p>
<p>If you download <a href="https://www.nuget.org/packages/System.Interactive.Async/3.0.0"><code>System.Interactive.Async</code> v3.0.0</a>, unzip the <code>nupkg</code>, and open up <code>System.Interactive.Async.dll</code> in ILDASM, you'll see that this library defines <code>IAsyncEnumerable&lt;T&gt;</code> and <code>IAsyncEnumerator&lt;T&gt;</code>. In fact, the Rx.NET team first published definitions of these interfaces back before NuGet existed, in 2010!</p>
<p>So <code>IAsyncEnumerable&lt;T&gt;</code> had been around for a decade by the time it was made an integral part of .NET, with the release of .NET Core 3.0. Rx.NET has always been very closely associated with LINQ, and so it also had a LINQ implementation for that whole time.</p>
<p>I don't know the history of why the .NET team chose <em>not</em> to provide LINQ for <code>IAsyncEnumerable&lt;T&gt;</code> back then. It's possible that the fact that there was no longer an Rx.NET team within Microsoft to advocate for this didn't help. Perhaps LINQ was out of favour within Microsoft at that time, and they underestimated the demand for this feature. And the fact that the (now open source) Rx.NET project already had an implementation (and the project's supporters, including some still working at Microsoft at the time, leapt into action to update that implementation to align with the fact that <code>IAsyncEnumerable&lt;T&gt;</code> had now moved into the runtime libraries) enabled them to believe that there was no need for the .NET runtime class library team to fill the gap.</p>
<p>There certainly <em>was</em> demand for this feature. The Rx.NET team packaged it in a few different ways over the years, but if we look just at the <code>System.Linq.Async</code> library the Rx.NET maintainers produced back in 2019, that has had a quarter of a <strong>billion</strong> downloads! And it is such an obviously useful feature that many people just assumed that the library was actually part of .NET. The package name certainly contributes to that perception—historically Rx.NET has always used <code>System</code> prefixes for its namespaces, because the Rx.NET team's vision was that Rx should be built right into .NET. (<code>IObservable&lt;T&gt;</code> did indeed make it into the class libraries in .NET Framework 4.0. And the versions of .NET that shipped in Windows Phone did actually include full Rx.NET libraries.)</p>
<p>But Microsoft has supplied no funding for the Rx.NET project for well over a decade. This made it impossible to keep up with people's expectations for what a high quality implementation of LINQ for <code>IAsyncEnumerable&lt;T&gt;</code> should be. When endjin took over maintenance of Rx.NET back in 2023, the company's owners very generously decided to pay for the time I spend working on it, but our motivation for this was that we believe in Rx.NET and want to keep it thriving. We ended up become responsible for the other projects in <a href="https://github.com/dotnet/reactive">https://github.com/dotnet/reactive</a> as a side effect, and we never wanted to be the guardians of LINQ to <code>IAsyncEnumerable&lt;T&gt;</code>.</p>
<p>Fortunately, Microsoft had by this time recognized that .NET developers expect LINQ for <code>IAsyncEnumerable&lt;T&gt;</code> to be available, and fully supported, and they offered to build it into the .NET runtime class libraries. And so, today, .NET 10.0 provides built-in support. We're happy that this is available to the .NET world, and that we can now focus our efforts on Rx.</p>
<h2 id="please-try-it-out">Please try it out</h2>
<p>This new 7.0 release of <a href="https://www.nuget.org/packages/System.Linq.Async"><code>System.Linq.Async</code></a> (and the corresponding <a href="https://www.nuget.org/packages/System.Interactive.Async"><code>System.Interactive.Async</code></a>) is available on NuGet today. If you're using Linq to <code>IAsyncEnumerable&lt;T&gt;</code>, please try upgrading (even if you're not yet on .NET 10). If you have any problems, please file issues at <a href="https://github.com/dotnet/reactive/issues">https://github.com/dotnet/reactive/issues</a>. Meanwhile, we hope you enjoy this new version of the Interactive Extensions for .NET.</p>
<h2 id="more-rx-content">More Rx content</h2>
<p>See my recent <a href="https://youtu.be/y7Ks_bwSHUg?si=bDmVEYyb8e0c156M">.NET conf talk about Rx.NET</a> for more information on this work, and our other Rx.NET activities.</p>]]></content:encoded>
    </item>
    <item>
      <title>The Data Product Canvas: The Theory Behind The Canvas</title>
      <description>The Data Product Canvas fuses the Business Model Canvas with Data Mesh's 'data as a product' principle, combining visual strategic collaboration with product-minded data ownership.</description>
      <link>https://endjin.com/blog/the-data-product-canvas-theory-behind-the-canvas</link>
      <guid isPermaLink="true">https://endjin.com/blog/the-data-product-canvas-theory-behind-the-canvas</guid>
      <pubDate>Wed, 22 Oct 2025 05:30:00 GMT</pubDate>
      <category>Data Product Canvas</category>
      <category>Business Model Generation</category>
      <category>Data Mesh</category>
      <category>Data Product</category>
      <category>Value Proposition</category>
      <category>User Centred Design</category>
      <category>Pivot</category>
      <category>Fail Fast</category>
      <category>Purpose Driven Design</category>
      <category>Data</category>
      <category>Analytics</category>
      <category>Microsoft Fabric</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/05/the-data-product-canvas-theory-behind-the-canvas.png" />
      <dc:creator>Barry Smart</dc:creator>
      <content:encoded><![CDATA[<p>TLDR; The Data Product Canvas combines two powerful frameworks: the Business Model Canvas, which revolutionized strategic planning through visual collaboration, and Data Mesh, which reimagines data as a product. By understanding these theoretical foundations, you'll gain deeper insight into why the canvas works and how to apply it most effectively in your organization. This final part of our series examines the origins of these frameworks and how they've been synthesized into a practical tool for data product design.</p>
<p>In <a href="https://endjin.com/blog/the-data-product-canvas-stop-building-products-that-fail">Part 1</a> of this series, we introduced the Data Product Canvas as a framework for designing data products that deliver real business value. <a href="https://endjin.com/blog/the-data-product-canvas-deep-dive-into-building-blocks">Part 2</a> explored each of the nine building blocks in detail, and <a href="https://endjin.com/blog/the-data-product-canvas-in-action">Part 3</a> showed the canvas in action through a worked example. Now, in this final instalment, we'll explore the theoretical foundations that make the canvas so effective.</p>
<p>Understanding the origins and principles behind the Data Product Canvas isn't just an academic exercise. By grasping the foundations, you'll be better equipped to:</p>
<ul>
<li>Adapt the canvas to your specific organisational context</li>
<li>Explain its value to stakeholders and secure buy-in</li>
<li>Apply its principles beyond the canvas itself to create a more product-centric data culture</li>
<li>Recognize when and how to modify the approach as your data maturity evolves</li>
</ul>
<p>Let's explore the two major frameworks that inspired the Data Product Canvas:</p>
<ul>
<li><strong>Business Model Canvas</strong> - a visual tool for designing, exploring and iterating on new strategies and product ideas.</li>
<li><strong>Data Mesh</strong> - specifically the concept of "data as a product", which is one of the 4 key principles applied in a Data Mesh architecture.</li>
</ul>
<h2 id="business-model-canvas">Business Model Canvas</h2>
<p>The Business Model Canvas (BMC) was developed by Alex Osterwalder and Yves Pigneur, with Osterwalder completing his PhD dissertation on business model ontology at the University of Lausanne in 2004. The formal publication came with their book <a href="https://www.amazon.co.uk/Business-Model-Generation-Visionaries-Challengers/dp/0470876417/">Business Model Generation</a> in 2010, which marked the canvas's widespread introduction to the business world.</p>
<div class="aside"><p>The Business Model Canvas marked a paradigm shift in business planning. By converting complex strategic thinking into a visual, collaborative format, it democratized strategy development and moved organizations away from static, text-heavy business plans toward dynamic, testable business hypotheses.</p>
</div>
<p>I discovered it on a bookshelf in Waterstones in my home town. It immediately grabbed my attention as it was the first business strategy book I had come across which:</p>
<ul>
<li>Provided a <strong>visual</strong> alternative to traditional, text-heavy business plans.</li>
<li>Seemed to make strategy development more <strong>accessible and collaborative</strong>, reducing the barrier to strategic thinking.</li>
<li>Allowed organisations to quickly imagine, <strong>prototype and iterate</strong> business models on paper before committing large amount of resources to an idea. Allowing them to shift from traditional annual business planning to more dynamic, iterative approaches.</li>
<li>Encouraged <strong>cross-functional collaboration</strong> in strategy development, in particular solving the "IT / business" divide.</li>
<li>Facilitated easier <strong>communication</strong> of complex business strategies.</li>
<li>Places the <strong>end-centred design</strong> at the heart of driving the design of new products and services.</li>
</ul>
<p>It simplifies complex strategic thinking into nine fundamental building blocks:</p>
<ol>
<li><strong>Customer Segments</strong> - the specific groups of people or organizations a business aims to serve and create value for.</li>
<li><strong>Value Proposition</strong> - the unique product or service that solves a customer problem or helps them to achieve a goal.</li>
<li><strong>Channels</strong> - the ways a company communicates with and delivers its value proposition to customers.</li>
<li><strong>Customer Relationships</strong> - the types of interactions and connections a business establishes and maintains with its customer segments.</li>
<li><strong>Revenue Streams</strong> - the various methods a company uses to generate income from its customer segments.</li>
<li><strong>Key Resources</strong> - the critical assets and resources required to deliver the value proposition effectively.</li>
<li><strong>Key Activities</strong>- the most important actions a company must perform to deliver its value proposition and operate successfully.</li>
<li><strong>Key Partnerships</strong> - the strategic relationships and networks that support and enhance the business model.</li>
<li><strong>Cost Structure</strong> - the total expenses associated with operating the business model across all its dimensions.</li>
</ol>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/01/strategyzer-business-model-canvas.png"><img src="https://endjin.com/assets/images/blog/2025/01/strategyzer-business-model-canvas.png" alt="Image of the Business Model Canvas - consisting of 9 building blocks" title="Image of the Business Model Canvas"></a></p>
<p>I first remember putting the Business Model Canvas into use in 2009 at ScottishPower. We used it to explore the future of customer experience within their Energy Networks business. It helped bring together a multi-disciplinary team that included the head of customer services, other members from her department, six sigma black belts and technology professionals. In the space of two hours we used a whiteboard version of the canvas and post-it notes to build situational awareness and explore possible strategies. It allowed us to develop a shared understanding, a common language for describing the challenges / opportunities ahead and clarity of purpose that we could all get behind. An immediate success!</p>
<p>I went onto use it in other roles. In one other notable case, it was used to help stakeholders in a 100 year old financial services firm understand how other organisations had successfully adopted new "digitally enabled" business models to disrupt their respective industries, diversify and sustain their business over the long term. We met on a monthly basis and used the Business Model Canvas to explore different case studies. It was "the thing that helped to get us to the thing" - in this case "the thing" being a deeper understanding how technology and data could be a force for good when it came to sustaining the firm for a further 100 years.</p>
<p>I wasn't alone. The tool was adopted by organisations of all shapes and sizes: corporations, non-profits, and government organisations. A global community of practitioners emerged, it was widely adopted in business schools and entrepreneurship programs and <a href="https://www.strategyzer.com/library/the-business-model-canvas">Strategyzer</a> (Osterwalder's company) became a global force in advising companies on business model innovation.</p>
<p>In 2019, following a "career reboot," I have been emersed in the world of data and AI. I had thought I would no longer need Business Model Canvas until I came across Data Mesh.</p>
<h2 id="data-mesh">Data Mesh</h2>
<p>Data Mesh was <a href="https://martinfowler.com/articles/data-monolith-to-mesh.html">first conceived by Zhamak Dehghani in 2018</a> while working as a principal technology consultant at ThoughtWorks. Dehghani developed the concept as a response to the limitations of traditional centralized data architecture approaches, particularly in large, complex organizations.</p>
<div class="aside"><p>While Data Mesh is often discussed primarily as an architectural approach, its most transformative aspect may be its recognition that data is fundamentally a socio-technical challenge. By treating data as a product with internal customers, it shifts the focus from technical implementation to value delivery.</p>
</div>
<p>Fundamentally, Data Mesh is a socio-technical approach to data management that treats data as a product. It challenges traditional centralized data warehouse models by promoting decentralized domain-oriented ownership, self-serve data infrastructure, and policy as code.</p>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/01/data-mesh-4-principles.png"><img src="https://endjin.com/assets/images/blog/2025/01/data-mesh-4-principles.png" alt="Image showing the 4 core principles of data mesh: data as a product, domain ownership, federated computational governance and self-serve data platform" title="Image showing the 4 core principles of data mesh"></a></p>
<p>The recognition that "data is a socio-technical endeavour" and the principle of "data as a product" in particular really appealed to me. It is aligned with the way that endjin have been approaching the delivery of data and analytics for the last decade. Largely I believe due to both Zhamak Dehghani and all of us at endjin coming from a software engineering background.</p>
<p>It recognizes that people are fundamental to success. If there are any issues with the people-related aspects, it will fundamentally undermine the overall success of that investment by impeding the flow of value, the understanding of end user needs, and the willingness of end users to adopt the solution, put it into use, and generate value from it.</p>
<p>By adopting a product mindset, data teams start to "think like entrepreneurs," adopting a proactive approach to delivering value while also accepting the responsibility for resource-allocation decisions by creating a clear link between data products, business value, and <a href="https://endjin.com/blog/what-is-total-cost-of-ownership-why-is-it-important">total cost of ownership (TCO)</a>, balancing inputs and outputs accordingly.</p>
<p>To succeed, these teams need to develop new tools to help them iterate on ideas to define what a "data product" actually is, and what's required to discover, build, and own a successful data product.</p>
<p>One such tool is a version of the Business Model Canvas that we have adapted for Data Products.</p>
<h2 id="data-product-canvas-business-model-canvas-data-mesh">Data Product Canvas = Business Model Canvas + Data Mesh</h2>
<p>The Data Product Canvas is a version of the Business Model Canvas adapted for iteratively envisaging and evaluating ideas for data products. We've renamed the 9 building blocks to better reflect the specific context of data products:</p>
<ul>
<li>Customer Segments ➡️ <strong>Audience</strong> - the specific groups of people that the data product is aiming to create value for.</li>
<li>Value Proposition ➡️ <strong>Actionable Insight</strong> - the data driven actionable intelligence that will be delivered by the data product to allow the audience to achieve a specific goal.</li>
<li>Channels ➡️ <strong>Consumption</strong> - the means through which the audience will access and use the data product.</li>
<li>Customer Relationships ➡️ <strong>Adoption</strong> - the support that will given to the audience to enable them to successfully discover and use the data product.</li>
<li>Revenue Streams ➡️ <strong>Lifetime Value</strong> - the value (tangible and intangible) that the data product is aiming to deliver over its lifetime.</li>
<li>Key Resources ➡️ <strong>Data Skills, Tools and Methods</strong> - the key capabilities that will be required to deliver and sustain the data product over its lifetime.</li>
<li>Key Activities ➡️ <strong>Data Processing</strong> - the actions that will need to be performed to transform data sources into the actionable insight.</li>
<li>Key Partnerships ➡️ <strong>Data Sources</strong> - the data sources which are required to deliver the actionable insight.</li>
<li>Cost Structure ➡️ <strong>Total Cost of Ownership</strong> - the projected costs to design, build, test, operate, maintain and evolve the data product over its lifetime.</li>
</ul>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/01/endjin-data-product-canvas.png"><img src="https://endjin.com/assets/images/blog/2025/01/endjin-data-product-canvas.png" alt="Image of the Data Product Canvas developed by endjin inspired by Business Model Generation and Data Mesh" title="Image of the Data Product Canvas"></a></p>
<h2 id="why-this-combination-works">Why This Combination Works</h2>
<p>The synthesis of these two frameworks creates something greater than the sum of its parts. Here's why this combination is so powerful:</p>
<h3 id="from-the-business-model-canvas">From the Business Model Canvas:</h3>
<ul>
<li><strong>Visual collaboration</strong>: The canvas format facilitates cross-functional discussions and breaks down silos between business and technical stakeholders.</li>
<li><strong>Holistic thinking</strong>: The nine-block structure ensures teams consider all critical aspects of a successful product, not just the technical implementation.</li>
<li><strong>Rapid iteration</strong>: The format encourages teams to quickly explore, validate, and refine ideas before committing significant resources.</li>
<li><strong>Strategic alignment</strong>: By making value explicit, the canvas ensures data initiatives support broader business objectives.</li>
</ul>
<h3 id="from-data-mesh">From Data Mesh:</h3>
<ul>
<li><strong>Product mindset</strong>: Treating data as a product shifts focus from technical implementation to user value and adoption.</li>
<li><strong>Socio-technical approach</strong>: Recognition that successful data initiatives require addressing both human and technical aspects.</li>
<li><strong>Domain orientation</strong>: Focusing on specific business domains rather than trying to solve all data problems at once.</li>
<li><strong>Value accountability</strong>: Making teams responsible for both the costs and benefits of their data products.</li>
</ul>
<div class="aside"><p>The canvas works even if you're not implementing Data Mesh as an architecture. The product-thinking principles it embodies are valuable regardless of your technical approach to data.</p>
</div>
<h2 id="beyond-the-framework-building-a-data-product-culture">Beyond the Framework: Building a Data Product Culture</h2>
<p>While the Data Product Canvas is a powerful tool, its greatest value may be in how it shifts organizational thinking about data. By applying the canvas consistently, organizations begin to develop:</p>
<ol>
<li><strong>Purpose-driven development</strong>: Moving from technology-first to purpose-first thinking</li>
<li><strong>User-centered design</strong>: Deeply understanding who will use data products and how</li>
<li><strong>Value measurement</strong>: Quantifying and tracking the actual business impact of data initiatives</li>
<li><strong>Lifecycle management</strong>: Considering the entire data product lifecycle, from inception to eventual retirement</li>
<li><strong>Sustainable resourcing</strong>: Making explicit decisions about ongoing investment based on realized value</li>
</ol>
<p>This culture shift is often more transformative than any specific data product that emerges from using the canvas.</p>
<h2 id="conclusion">Conclusion</h2>
<p>The Data Product Canvas represents a powerful synthesis of business model thinking and data product principles. By combining the visual collaboration and holistic thinking of the Business Model Canvas with the product mindset and socio-technical approach of Data Mesh, we've created a practical tool that helps organizations overcome the most common causes of data product failure.</p>
<p>Throughout this four-part series, we've:</p>
<ul>
<li>Introduced the canvas and explained why traditional approaches to data products often fail</li>
<li>Explored each building block in detail with practical guidance</li>
<li>Demonstrated the canvas in action through a real-world scenario</li>
<li>Examined the theoretical foundations that make the canvas effective</li>
</ul>
<p>The Data Product Canvas isn't just another framework to follow. It's a catalyst for a fundamentally different approach to data initiatives—one that starts with purpose, focuses relentlessly on user needs, quantifies value, and considers implementation realities. By adopting this approach, organizations can dramatically improve their success rate with data products and build a sustainable competitive advantage through data.</p>
<p>Whether you're just beginning your data journey or looking to improve the impact of an established data practice, the canvas provides a structured, collaborative way to ensure your data investments deliver real business value. We encourage you to download the canvas, adapt it to your needs, and use it to transform how your organization approaches data products.</p>
<p>The journey to becoming truly data-driven isn't about technology—it's about purposefully creating products that help people make better decisions and take more effective actions. The Data Product Canvas is your guide on that journey.</p>
<p>If you would like an Adobe Acrobat version of the canvas, please <a href="https://endjin.com/contact-us/">reach out to us</a>.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Data Product Canvas</h3>
        <span class="series-toc__count">4 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/the-data-product-canvas-stop-building-products-that-fail" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Introducing The Data Product Canvas</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/the-data-product-canvas-deep-dive-into-building-blocks" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Deep Dive into the Building Blocks</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/the-data-product-canvas-in-action" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">The Canvas in Action</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">4.</span>
                <span class="series-toc__part-title">The Theory Behind The Canvas</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>The Data Product Canvas in Action</title>
      <description>See the Data Product Canvas in action with a real-world scenario. Follow along as we work through each building block to design a high-impact, feasible data product for a national garden center chain facing revenue challenges.</description>
      <link>https://endjin.com/blog/the-data-product-canvas-in-action</link>
      <guid isPermaLink="true">https://endjin.com/blog/the-data-product-canvas-in-action</guid>
      <pubDate>Tue, 21 Oct 2025 05:30:00 GMT</pubDate>
      <category>Data Product Canvas</category>
      <category>Business Model Generation</category>
      <category>Data Mesh</category>
      <category>Data Product</category>
      <category>Value Proposition</category>
      <category>User Centred Design</category>
      <category>Pivot</category>
      <category>Fail Fast</category>
      <category>Purpose Driven Design</category>
      <category>Data</category>
      <category>Analytics</category>
      <category>Case Study</category>
      <category>Microsoft Fabric</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/05/the-data-product-canvas-in-action.png" />
      <dc:creator>Barry Smart</dc:creator>
      <content:encoded><![CDATA[<p>TLDR; Put yourself in the shoes of a Head of Data and Analytics as you navigate the challenges of creating a high-impact data product for a struggling garden center chain. Experience first-hand how the Data Product Canvas helps you align business goals with technical capabilities, anticipate adoption challenges, and quantify value — all within one week. Through a detailed narrative, see how each canvas component comes to life, ultimately leading to a data product with potential 10X return on investment that wins enthusiastic board approval.</p>
<p>In <a href="https://endjin.com/blog/the-data-product-canvas-stop-building-products-that-fail">Part 1</a> of this series, we introduced the Data Product Canvas as a framework for designing data products that deliver real business value. In <a href="https://endjin.com/blog/the-data-product-canvas-deep-dive-into-building-blocks">Part 2</a>, we explored each of the nine building blocks in detail. Now, it's time to see the canvas in action through a worked example that demonstrates how it shapes decision-making in a real-world scenario.</p>
<p>Here is a completed canvas for our example:</p>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/01/endjin-data-product-canvas-worked-example.png"><img src="https://endjin.com/assets/images/blog/2025/01/endjin-data-product-canvas-worked-example.png" alt="Worked example of endjin's data product canvas" title="Worked example of data product canvas"></a></p>
<p>This was created using a Microsoft Visio template. If you are interested in a copy of this, please reach out to us at <a href="mailto:hello@endjin.com">hello@endjin.com</a>.</p>
<h2 id="background-your-first-100-days">Background: Your First 100 Days</h2>
<p>After the boom that came about as a result of the Covid-19 pandemic, when millions of people spending more time at home found a new interest in tending to their gardens, GreenGrow (your garden center chain employer) has struggled to maintain a steady, predictable stream of revenue. At the same time, your company's main competitor is enjoying strong performance, putting pressure on the board.</p>
<p>You've just completed your first 100 days as the new Head of Data and Analytics. Walking into the boardroom for your first major strategy presentation, you feel a mix of nervousness and excitement. This is your moment to make a strong first impression, but also to set realistic expectations about what's possible.</p>
<p>You take a deep breath and begin your presentation. You outline how you want to deliver value rapidly and frequently through targeted data products, each focusing on a specific area of the business where data can deliver high-value impact. You also emphasize something that many previous tech leaders have failed to communicate effectively - that your team can't succeed in isolation.</p>
<p>"Data is a socio-technical endeavour," you explain, seeing a few puzzled looks. "Even the best technical solution will fail if we don't address the cultural and organizational aspects that often become barriers."</p>
<p>You share several case studies of how other organizations have transformed and gained competitive advantage by treating data as an asset. To your relief, the board members lean forward, actively engaged in the conversation. They're forward-thinking and can see the opportunity to take better decisions, more rapidly, and better understand the dynamics of their business by becoming more data-driven.</p>
<p>When you ask the board to prioritize which areas of the business your team should focus on, they're unanimous: address the recent string of poor quarterly financial results where revenue performance has been well below forecasted levels. They want to understand the root causes to stabilize performance and restore investor confidence.</p>
<p>They also mention a secondary concern: declining customer satisfaction. Recent Net Promoter Score (NPS) surveys show a marked decrease in customer happiness. Could this be linked to the revenue issues?</p>
<p>The Chief Financial Officer immediately offers to sponsor this work. She's a tech-savvy CFO who sees numerous opportunities to use a data-driven approach to drive decision making. "Let's work together on this," she says. "I can help you get time with the key stakeholders. Let's come back to the board next week with some data product proposals that could help meet these goals."</p>
<p>You leave the meeting feeling elated - it went even better than you'd hoped! You have full backing to move forward and clear direction on which areas to focus on.</p>
<p>But reality quickly sets in. You only have a week to develop compelling data product ideas for the board to consider. Fortunately, you have two key advantages: the CFO's active support and a tool you've used successfully before - the Data Product Canvas.</p>
<h2 id="your-first-strategy-session">Your First Strategy Session</h2>
<p>In your initial catch-up with the CFO after the board meeting, you start exploring the challenge in more depth. She immediately hones in on one specific issue that has undermined both revenue targets and customer satisfaction: failures across all stores to proactively build up stock of products that are coming into peak demand.</p>
<p>"Have you seen this?" she asks, pulling up a recent press article on her tablet. The headline reads "Garden Centers Leave Customers Disappointed by Empty Shelves." The article highlights customer frustration during Spring when they arrived at stores to find they had sold out of high-demand seasonal items like vegetable seeds, annuals, and bulbs.</p>
<p>"This isn't an isolated incident," she continues. "We've had similar problems at other peak times. It's killing our revenue and driving customers to competitors."</p>
<p>You ask how stock levels are currently determined. The CFO explains that they're set by individual garden center managers based on experience and "gut feel." Given that the company has grown through acquisition of independent garden centers, these managers tend to have their own opinions on how to run their stores.</p>
<p>"I should warn you," she adds, "you may face resistance to new data-driven methods. These managers are used to doing things their way."</p>
<p>You make a mental note of this potential adoption challenge. "That's really helpful to know upfront," you reply. "Would you be willing to help create incentives for store managers to engage with whatever solution we develop?"</p>
<p>She nods. "Absolutely. I can build it into their performance metrics. And we should definitely monitor adoption and outcomes closely."</p>
<p>You both agree there's an opportunity to get something in place within the next three months to catch the upcoming winter season when there's another surge in demand - this time for Christmas trees and decorations.</p>
<p>As you leave the meeting, your mind is already mapping out the canvas approach. This is exactly the kind of challenge where the Data Product Canvas can shine - helping to rapidly define a focused solution with clear business impact.</p>
<h2 id="populating-the-canvas-your-journey-begins">Populating the Canvas: Your Journey Begins</h2>
<p>You remember the recommendation from Part 2 of this blog series: start with a purpose-driven approach. That means beginning with understanding the Audience to validate the Goal and capture the Actionable Insight that will enable that audience to achieve their goal.</p>
<h3 id="starting-with-audience">Starting with Audience</h3>
<p>You set up brief interviews with three different Garden Center Managers. Going into these conversations, you're conscious of potential resistance, so you focus on building rapport first, showing genuine interest in their roles and challenges.</p>
<p>To your relief, they respond positively. You listen carefully as they describe their daily reality - the constant juggling act between dealing with operational firefighting and trying to plan ahead. Their primary concerns revolve around keeping customers happy so they return regularly, and retaining their staff.</p>
<p>"Most days, I barely have time to breathe," one manager tells you with a sigh. "I'm constantly putting out fires - dealing with staffing issues, customer complaints, supplier problems. Finding time to think strategically about what stock we'll need in three months? That's a luxury."</p>
<p>You note these time constraints - any solution you develop will need to be efficient and low-effort for managers to adopt.</p>
<h3 id="identifying-the-goal-and-designing-the-actionable-insight">Identifying the Goal and Designing the Actionable Insight</h3>
<p>When you bring up the board's goal of maximizing revenue and increasing customer satisfaction, the managers nod in agreement. "Absolutely, that's what we're all trying to do," one says.</p>
<p>"What actions are you empowered to take that would help achieve this goal?" you ask.</p>
<p>They immediately highlight stock management and the recent incidents where garden centers ran out of items in high demand.</p>
<p>"The worst feeling is seeing a customer walk out empty-handed because we don't have what they want," one manager explains. "But the flip side is, I'm really nervous about over-ordering. I hate seeing good product going to waste."</p>
<p>Another manager adds, "I just don't have the time to analyze what items might be coming into demand in the next few months. And our ordering system takes forever to use. I'd love some help with knowing what to order and when."</p>
<p>You're beginning to see a clear picture of the problem and potential solution. You settle on the key question that needs answering: "What products should we be stocking to maximize sales next month?"</p>
<p>When you ask how this information would need to be presented to be useful, they're specific: "A simple list of products predicted to be in high demand next month, showing current stock level, target level, and the gap we need to fill."</p>
<p>You validate the action this would enable: proactively ordering stock from suppliers to meet upcoming demand - which directly contributes to the revenue and customer satisfaction goals.</p>
<p>As you wrap up these conversations, you feel a growing sense of excitement. There's a clear opportunity here, with a direct line between data, insight, and business impact.</p>
<h3 id="consumption-and-adoption-making-it-work-in-the-real-world">Consumption and Adoption: Making It Work in the Real World</h3>
<p>Having outlined the actionable insight, your focus turns to what will be required for garden center managers to successfully adopt and use it.</p>
<p>Initially, you explore the possibility of automated integration into the corporate ERP platform, but after discussing the technical realities with your team and IT, you agree that in the short term, it's best to have a human in the loop. Based on this, you decide an online report filtering dynamically to focus on an individual garden center's stock level is the best presentation method.</p>
<p>Thinking about adoption, you identify several important considerations:</p>
<p>During your interviews, you discovered the garden center managers are generally not familiar with Power BI, your preferred reporting tool. "I'll need to create a video walkthrough to help them get comfortable with the new report," you think to yourself.</p>
<p>You also learned that at least one garden center manager is colour blind (which isn't surprising, given that 1 in 20 people have color vision deficiency). You make a note to design the report with accessibility in mind.</p>
<p>Given the engagement level you've seen from the managers, you see an opportunity to build community around the new data product. "A monthly town hall session would be perfect for gathering feedback and ideas about future enhancements," you think. "That could really help with adoption."</p>
<p>When exploring consumption in more depth, several requirements become clear:</p>
<p>The output needs to be a printable table of products with recommended stock levels and ordering levels, so managers can use it during stock checks and order entry. You make a note that a paginated report will be required.</p>
<p>You confirm with the CFO that the report should refresh on a monthly cycle, with email notifications to users. She also specifies that, initially, garden center managers should only see data for their own store, meaning you'll need to implement row-level security.</p>
<h3 id="lifetime-value-quantifying-the-impact">Lifetime Value: Quantifying the Impact</h3>
<p>Working with the CFO, you create a view of the lifetime value of the data product over five years. As you quantify the potential impact on revenue, reduction in write-offs (waste), and customer satisfaction, you both become increasingly excited.</p>
<p>"If we can reduce stockouts by even 25%, the revenue impact would be substantial," the CFO calculates. "And the reduction in waste from more precise ordering could save us significant costs too."</p>
<p>The numbers are compelling. Looking at the analysis, you feel a surge of confidence - this idea could be transformative for the business.</p>
<h3 id="data-sources-and-processing-exploring-feasibility">Data Sources and Processing: Exploring Feasibility</h3>
<p>Now it's time to assess technical feasibility. You bring together your team and partners from IT to identify data sources and processing requirements.</p>
<p>To your relief, most of the data you need exists in the corporate ERP platform. However, as is often the case, there are secondary data sources maintained in Excel spreadsheets containing important reference data.</p>
<p>Your team assesses the quality of these data sources, identifying some issues that will need addressing. "This is manageable," your data engineer confirms. "We'll need to build in some quality checks, but the core data looks solid."</p>
<h3 id="data-skills-tools-and-methods-identifying-capability-gaps">Data Skills, Tools and Methods: Identifying Capability Gaps</h3>
<p>When evaluating the capabilities needed, you identify a gap around machine learning model development. While your team is confident about the data engineering and report building aspects, they express concern about the ML component.</p>
<p>"We can handle the ETL and reporting," your senior analyst says, "but the demand prediction model is outside our comfort zone. We've not done anything quite like this before."</p>
<p>You consider the options and decide the best approach in the short term is to bring in external expertise from a consultancy you've worked with previously. This will add cost, but it will also help de-risk the project and provide valuable knowledge transfer to your team.</p>
<p>You're also acutely aware that ML projects are experimental by nature. There's no guarantee you'll be able to develop a model that meets the minimum acceptance criteria. You make a point of stressing this with the CFO during your next meeting.</p>
<p>"I appreciate your honesty," she responds. "What would you say is a reasonable budget to test if this is feasible before we commit to full implementation?"</p>
<p>You discuss an appropriate "learning budget," and she agrees to your proposed figure. "If it works, the ROI will be tremendous. If not, we'll have learned something valuable without breaking the bank."</p>
<h3 id="total-cost-of-ownership-tco-making-the-financial-case">Total Cost of Ownership (TCO): Making the Financial Case</h3>
<p>Finally, you work with colleagues in IT and finance to build a comprehensive view of the TCO over the five-year lifetime of the data product.</p>
<p>The analysis indicates a potential 10X return on investment - far exceeding the typical threshold for project approval. The CFO is visibly excited as you review the numbers together.</p>
<p>"This is exactly the kind of focused, high-impact initiative we need," she says. "I'm confident the board will approve this. Let's finalize the presentation."</p>
<h2 id="the-board-presentation-moment-of-truth">The Board Presentation: Moment of Truth</h2>
<p>A week after the initial board meeting, you return to present your proposal. Despite having slept only a few hours the night before (finalizing the presentation took longer than expected), you feel confident.</p>
<p>The Data Product Canvas has helped you create a comprehensive, well-thought-out proposal in just one week. You've identified a clear business need, designed a targeted solution, assessed technical feasibility, and quantified the potential value.</p>
<p>As you walk the board through the proposal, you can see heads nodding. The CFO adds her strong endorsement, emphasizing how the solution directly addresses their concerns about revenue performance and customer satisfaction.</p>
<p>When you acknowledge the experimental nature of the ML component and the "learning budget" approach, the board appreciates your transparency. "This is a refreshing change," the CEO comments. "Usually, tech projects promise the moon and then fail to deliver. I like this pragmatic approach."</p>
<p>After some thoughtful questions, the board unanimously approves the initiative. As you leave the boardroom, you feel a mixture of elation and the weight of responsibility. You've secured approval and set appropriate expectations - now comes the hard part of delivering.</p>
<h2 id="reflecting-on-the-journey">Reflecting on the Journey</h2>
<p>Back in your office, you review the completed canvas once more. In just one week, it's helped you transform a broad strategic mandate into a specific, actionable data product with clear business value.</p>
<p>What strikes you most is how the canvas helped balance business and technical considerations. Starting with the audience and their needs kept the solution focused on delivering real value, while the systematic exploration of technical requirements ensured you weren't promising something impossible.</p>
<p>You also appreciate how the canvas helped you identify and address potential adoption challenges upfront. By involving garden center managers early and understanding their needs and constraints, you've designed a solution they're more likely to embrace.</p>
<p>Most importantly, the canvas provided a structured way to communicate your thinking to the board and secure their support. The comprehensive nature of the analysis gave them confidence that you'd considered all key aspects of the initiative.</p>
<p>As you send the approved canvas to your team to begin implementation planning and detailed design, you reflect on how different this feels from previous technology initiatives you've been involved with. Instead of starting with technology and hoping it creates value, you've started with value and found the right technology to deliver it.</p>
<h2 id="conclusion">Conclusion</h2>
<p>The Data Product Canvas transformed what could have been a stressful week of scrambling to develop proposals into a structured process that produced a compelling, well-thought-out data product concept. By systematically working through each building block, you were able to:</p>
<ul>
<li>Deeply understand user needs and constraints</li>
<li>Identify a focused, high-impact opportunity</li>
<li>Design a solution that balanced ambition with feasibility</li>
<li>Anticipate and address potential adoption challenges</li>
<li>Build a compelling business case with quantified value</li>
</ul>
<p>Most importantly, the canvas helped you navigate the socio-technical complexity of data initiatives. It wasn't just about data and algorithms - it was about people, processes, and organizational dynamics.</p>
<p>This experience reinforces why a purpose-driven, holistic approach to data products is essential. By considering all aspects - from user needs to technical implementation to organizational change - you've dramatically increased the likelihood of delivering real business impact.</p>
<p>For anyone in a data leadership role facing similar challenges, the Data Product Canvas provides a structured path from vague strategic directives to concrete, valuable data products. It helps you ask the right questions, involve the right stakeholders, and build solutions that deliver tangible business value.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Data Product Canvas</h3>
        <span class="series-toc__count">4 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/the-data-product-canvas-stop-building-products-that-fail" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Introducing The Data Product Canvas</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/the-data-product-canvas-deep-dive-into-building-blocks" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Deep Dive into the Building Blocks</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">3.</span>
                <span class="series-toc__part-title">The Canvas in Action</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/the-data-product-canvas-theory-behind-the-canvas" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">The Theory Behind The Canvas</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>The Data Product Canvas: Deep Dive into the Building Blocks</title>
      <description>The Data Product Canvas has nine building blocks, best completed right-to-left starting with Audience and Actionable Insight, to keep data products purpose-driven and user-centred.</description>
      <link>https://endjin.com/blog/the-data-product-canvas-deep-dive-into-building-blocks</link>
      <guid isPermaLink="true">https://endjin.com/blog/the-data-product-canvas-deep-dive-into-building-blocks</guid>
      <pubDate>Mon, 20 Oct 2025 05:30:00 GMT</pubDate>
      <category>Data Product Canvas</category>
      <category>Business Model Generation</category>
      <category>Data Mesh</category>
      <category>Data Product</category>
      <category>Value Proposition</category>
      <category>User Centred Design</category>
      <category>Pivot</category>
      <category>Fail Fast</category>
      <category>Purpose Driven Design</category>
      <category>Data</category>
      <category>Analytics</category>
      <category>Microsoft Fabric</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/05/the-data-product-canvas-deep-dive-into-building-blocks.png" />
      <dc:creator>Barry Smart</dc:creator>
      <content:encoded><![CDATA[<p>TLDR; The Data Product Canvas provides a structured approach to designing high-value, sustainable data products. This strategic tool brings stakeholders together to align business needs with technical capabilities before committing resources. This deep dive explores each of the nine building blocks, showing you how to complete them effectively to maximize your chances of success.</p>
<p>In <a href="https://endjin.com/blog/the-data-product-canvas-stop-building-products-that-fail">Part 1</a> of the blog, we introduced the Data Product Canvas as a blueprint for success - a simple yet powerful tool that aims to bring stakeholders together to shape data products that deliver real value.</p>
<p>It does this by presenting 9 simple building blocks that will encourage you to think holistically about the data product.</p>
<ul>
<li><strong>Audience</strong> - the specific groups of people that the data product is aiming to create value for.</li>
<li><strong>Actionable Insight</strong> - the data driven actionable intelligence that will be delivered by the data product to allow the audience to achieve a specific goal.</li>
<li><strong>Consumption</strong> - the means through which the audience will access and use the data product.</li>
<li><strong>Adoption</strong> - the support that will be given to the audience to enable them to successfully discover and use the data product.</li>
<li><strong>Lifetime Value</strong> - the value (tangible and intangible) that the data product is aiming to deliver over its lifetime.</li>
<li><strong>Data Sources</strong> - the data sources which are required to deliver the actionable insight.</li>
<li><strong>Data Processing</strong> - the actions that will need to be taken to transform data sources into the actionable insight.</li>
<li><strong>Data Skills, Tools and Methods</strong> - the key capabilities that will be required to deliver and sustain the data product over its lifetime.</li>
<li><strong>Total Cost of Ownership</strong> - the projected costs to design, build, test, operate, maintain and evolve the data product over its lifetime.</li>
</ul>
<p>Here is the blank canvas:</p>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/01/endjin-data-product-canvas.png"><img src="https://endjin.com/assets/images/blog/2025/01/endjin-data-product-canvas.png" alt="Image of the Data Product Canvas developed by endjin inspired by Business Model Generation and Data Mesh" title="Image of the Data Product Canvas"></a></p>
<p>If you would like an Adobe Acrobat version of the canvas, please <a href="https://endjin.com/contact-us/">reach out to us</a>.</p>
<p>In this blog, we describe each of the blocks in more detail <strong>in the general order that we recommend you should complete the canvas</strong>. Providing reference material that we hope will help you to adopt the canvas successfully.</p>
<h2 id="in-which-order-should-you-complete-the-canvas">In which order should you complete the canvas?</h2>
<p>We recommend <strong>starting with the centre of the model</strong> to capture the <strong>actionable insight</strong>.  This ensures that you start with "Why?" and adopt a <strong>purpose driven approach</strong> to designing your data product.</p>
<p>Having captured the actionable insight, we then recommend you then focus on the <strong>right-hand-side</strong> of the canvas to reinforce the principle that successful products apply <strong>user-centred-designed</strong> through a deep understanding of a the audience who will use the product.</p>
<p>Once you have validated the fit between actionable insight and audience, you should then complete the <strong>left-hand-side</strong> of the model to <strong>test the feasibility</strong> by validating the data, processes and wider capabilities are in place to enable the actionable insight to be delivered.</p>
<p>This approach is illustrated below:</p>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/07/endjin-data-product-canvas-order.png"><img src="https://endjin.com/assets/images/blog/2025/07/endjin-data-product-canvas-order.png" alt="Image of the Data Product Canvas overlaid with order in which we recommend it should be completed." title="Image of the Data Product Canvas overlaid with order in which we recommend it should be completed."></a></p>
<p>Note - the process of completing the canvas is seldom linear.  You will tend to loop back and iterate on all parts of the canvas as you uncover new information and develop your understanding.  So don't be constrained to the flow illustrated above!</p>
<h3 id="actionable-insight">Actionable Insight</h3>
<p>Start with <strong>Why?</strong> by <a href="https://endjin.com/blog/insight-discovery-03-defining-actionable-insights">defining the actionable insight</a>.</p>
<p>An actionable insight is a "quantum" of functionality. It should have a narrow focus based on:</p>
<ul>
<li>Goal - what goal is the audience seeking to achieve?</li>
<li>Question - what question needs to be answered that will enable them to achieve this goal?</li>
<li>Insight - what information is required to answer that question?</li>
<li>Action - what action will the insight enable the audience to take that will contribute to the goal?</li>
</ul>
<p>See some examples below:</p>
<table>
<thead>
<tr>
<th>Goal</th>
<th>Question</th>
<th>Insight</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr>
<td>Protect revenue</td>
<td>Which clients are we at risk of losing?</td>
<td>Top 10 clients at risk</td>
<td>Proactive intervention to engage with clients who deemed to be at risk</td>
</tr>
<tr>
<td>Maximise revenue, increase customer satisfaction</td>
<td>What products should we be stocking to maximise sales next month?</td>
<td>Top 10 products in high demand next month</td>
<td>Proactively order stock from suppliers to meet demand for coming month</td>
</tr>
<tr>
<td>Reduce customer churn, achieve SLAs</td>
<td>What staffing levels do I need tomorrow?</td>
<td>Predicted demand and recommended staffing level for the next shift</td>
<td>Authorise overtime to tomorrow to proactively resource up for anticipated spike in demand</td>
</tr>
</tbody>
</table>
<p>Mapping out these elements will ensure the data product will:</p>
<ul>
<li>Deliver insights that can be acted upon</li>
<li>Contribute directly to a goal of the organisation</li>
<li>Be measurable in terms of value and impact</li>
</ul>
<p>The clearer this understanding, the more likely the data product will succeed.  It will help you to avoid "scope creep" and keep all stakeholders focused on the goal.</p>
<p>You will often need to do some detective work to uncover the goal and actionable insight.  Users tend to communicate the "solution" rather than the "requirement", so be prepared to ask "Why?" a few times to get to the raw requirements, for example:</p>
<ul>
<li>End user: "We need a new stock dashboard."</li>
<li>You: "Why do you need this report?"</li>
<li>End user: "So we can see the level of stock we have for each product at each store."</li>
<li>You: "Why do you need to understand stock levels at this level of detail?"</li>
<li>End user: "We use this information to understand where stock levels are running low relative to anticipated demand." (the <strong>INSIGHT</strong>)</li>
<li>You: "Why do you need to understand when stock levels are running low?"</li>
<li>End user: "So we can proactively order stock from suppliers and prevent the shelves from going empty." (the <strong>ACTION</strong>)</li>
<li>You: "Why is this important?"</li>
<li>End user: "It is critical to maximising revenue and to keep our customers happy and returning to shop with us!" (the <strong>GOAL</strong>)</li>
</ul>
<p>Other aspects of the actionable insight that you should also consider at this stage:</p>
<ul>
<li>Related data products - other data products that this actionable insight could leverage. This is typically an upstream data product which it can consume as an input.</li>
<li>Service levels - define the service level objectives that need to achieved for the actionable insight to be trustworthy. Typically, this would include considerations such as:
<ul>
<li>Accuracy</li>
<li>Completeness</li>
<li>Availability</li>
</ul>
</li>
<li>Risks - the risks that you have identified that could manifest to prevent the actionable insight from being delivered in a sustainable and reliable way.</li>
</ul>
<h3 id="audience">Audience</h3>
<p>In this building block you identify the audience that is responsible for putting the data product into use to generate value.</p>
<p>Each action in the examples provided in the table above should be something the audience has the authority and capability to execute. For instance:</p>
<ul>
<li>Customer service managers can authorize overtime</li>
<li>Procurement teams can order stock</li>
<li>Account managers can intervene with at-risk clients</li>
</ul>
<p>If the insight suggests actions that the audience can't take, it's not truly "actionable" for them. This connects back to understanding the audience's "key activities" - what they're actually empowered to do in their role.</p>
<div class="aside"><p>If your audience can't take action based on the insight you provide, you haven't created an actionable insight—you've created an interesting observation.</p>
</div>
<p>A good product is founded on a deep understanding of end users and designing it with their needs in mind. The most successful products are loved by end users because they generate value for them by allowing them to overcome a specific "pain" or achieve some kind of "gain".</p>
<div class="aside"><p>Without a clear understanding of your audience, even the most technically impressive data product will struggle to find adoption.</p>
</div>
<p>This building block encourages us to understand the end users, existing methods, how they will prefer to consume the data product to achieve a specific goal.  Here we identify the "personas" who will consume the data product. By persona we mean a specific person (e.g. the Chief Financial Officer), role (e.g. Customer Service Agents) or demographic group (e.g. University Students).</p>
<p>For each persona we should explore:</p>
<ul>
<li>Responsibilities - what business outcomes are they responsible for and how is their success measured?</li>
<li>Key Activities - what actions are they authorised to take in order to fulfil their responsibilities? What resources do they have control over in terms of budget, team etc.?</li>
<li>Inhibitors - what gets in the way of them achieving their responsibilities? This could be information gaps, decision-making bottlenecks, compliance challenges and quality issues.</li>
</ul>
<p>We should also consider factors that will have a direct impact on the design of the data product such as:</p>
<ul>
<li>Level of data literacy and technical skills when it comes to embracing the data product</li>
<li>Existing tools that they use</li>
<li>Time constraints</li>
<li>Existing work patterns</li>
</ul>
<p>By understanding your audience, you will ensure that the data product will:</p>
<ul>
<li>Deliver an insight the audience can actually act on</li>
<li>Fit naturally with existing skills and workflow</li>
<li>Overcome barriers to adoption</li>
</ul>
<p>The clearer the understanding you build, the more likely the data product will achieve adoption and generate value.</p>
<h3 id="consumption">Consumption</h3>
<p>Define how the audience will access and consume the actionable insight:</p>
<ul>
<li>Nature of the actionable insight - in other words, the delivery mechanism or means through which the actionable insight is consumed: report, dashboard, alert, document, tabular data, data API, interactive model, knowledge graph etc.</li>
<li>Device - the type of device they will access the actionable insight on. This can have significant implications for the data product. This could be a personal device, or some kind of downstream platform which is seeking to consume the data product.</li>
<li>When? - how often will they actually need to consume the data product - hourly, daily, weekly, monthly? How up to date will the actionable insight need to be to be useful? How will they be notified when new insights are available or have been refreshed?</li>
<li>Security &amp; permissions - is concerned with authentication and authorization controls. How will users (or machines) authenticate? What information should they see / permissions should they have depending on their role?</li>
</ul>
<div class="aside"><p>The most sophisticated analysis is worthless if delivered in a format users can't or won't consume. Meet users where they are, not where you wish they were.</p>
</div>
<h3 id="adoption">Adoption</h3>
<p>In this area of the canvas we consider how end users will find, gain access to and successfully adopt the data product:</p>
<ul>
<li>Discovery - explains how users find, learn about and access the data product. You should also consider established users will be notified when new features (versions) have been developed.</li>
<li>Design - a topic that is often overlooked and can have a significant impact on the successful adoption of a data product. This is specific to the nature of the data product. Reports will be subject to design considerations such as visual impairments and branding, whilst APIs will be subject to design considerations such as design of API (GraphQL versus OpenAPI), availability of "try it out" web site and code samples.</li>
<li>Documentation, Support &amp; Training - describe the documentation, training and support will be available. You should also consider who should they turn to when they have a question or encounter an issue. Documentation should include information about data quality (data contract) and lineage.</li>
<li>Feedback loop - describe how end users will provide feedback about their experience using the data product. Also consider how usage and adoption be measured over time.</li>
</ul>
<h3 id="lifetime-value">Lifetime Value</h3>
<p>Quantifies the business value that the data product will generate over its lifetime. The lifetime of a data product typically being at least 5 years.</p>
<p>Examples include tangible value such as:</p>
<ul>
<li>Revenue growth</li>
<li>Cost savings</li>
<li>Productivity</li>
</ul>
<p>But also more intangible sources of value to the organisation such as:</p>
<ul>
<li>Risk reduction</li>
<li>Strategic advantages</li>
<li>Protecting brand / reputation</li>
<li>Enhancing customer experience</li>
<li>Employee satisfaction and retention</li>
</ul>
<div class="aside"><p>The most successful data products often start by delivering small but immediate value, then expand their impact over time as adoption grows and capabilities evolve.</p>
</div>
<p>Tailoring the value drivers in this section to the goals of the organisation is a useful way of ensuring that data product ideas are tested against and aligned to the business strategy.</p>
<h3 id="data-sources">Data Sources</h3>
<p>Identifies the specific data sources required to deliver the actionable insights. Here, you should consider:</p>
<ul>
<li>Nature of the data source - which will typically be:
<ul>
<li>Business application in the operational plane - an application that is used to run the business such as an ERP platform?</li>
<li>Service - is it an external service that the organisation has access to such as Bloomberg?</li>
<li>Master and reference data - is it some form of master / reference data that is typically required to augment and integrate operational data?</li>
<li>Another data product?</li>
</ul>
</li>
<li>Trust - what level of trust do you place in each data source? Data sources with a low level of trust should immediately be a red flag for any data product idea!</li>
<li>Classification - how is the data classified? Is it information that is considered low value / low risk? Or is it high value and / or high risk in nature such as proprietary information about individuals that only your organisation has access to?</li>
<li>Compliance - are there specific policies, regulations and laws that need to be applied? For example GDPR?</li>
<li>Ownership of the data - does the organisation own the data or is it owned by an external party? If internal, which department owns it? Are they prepared to grant access?</li>
<li>Data Characteristics - consider the general nature of the information for each (here we use the "5Vs" framework):
<ul>
<li>Volume - the amount of data and how it will grow over time.</li>
<li>Velocity - the speed at which data changes and needs to be processed.</li>
<li>Variety: the range of data types that need to be consumed - structured, unstructured and semi-structured.</li>
<li>Veracity: the quality and accuracy of the data.</li>
<li>Value: licenses required to use the data.</li>
</ul>
</li>
</ul>
<p>In all of the above, you are not looking for precise or detailed answers. It's a case of using your expertise to highlight the characteristics that will have a significant influence on the technical complexity, feasibility and overall TCO of the data product. The single page canvas, with limited space, will force you to keep it focused on the most important characteristics.</p>
<h3 id="data-processing">Data Processing</h3>
<p>Defines the key activities that need to performed to transform the data sources into an actionable insight. This includes the following activities and considerations:</p>
<ul>
<li>Ingestion - how will data be ingested from source?</li>
<li>Quality Assurance - does data need to be validated before it can be used? What "data contract" needs to be fulfilled to enable the data product to be viable?</li>
<li>Processing - what processing is required? Cleaning, standardisation, integration, transformation, filtering?</li>
<li>Projection - in what form should the data be presented for downstream consumption? For example, does it need to transformed into a single flat table, a star schema or a knowledge graph?</li>
<li>Modelling - what form of analytics or modelling processes need to be applied to source data? For example, is data inference being applied to address gaps in data? Is a machine learning model being applied to cluster data or enable predictive analytics?</li>
<li>Automation - what level of automation is required?</li>
<li>Triggers - when / how often should the process run?</li>
</ul>
<div class="aside"><p>Data processing requirements should be driven by the needs of the actionable insight, not by the capabilities of your existing tools or team preferences.</p>
</div>
<p>As above, the purpose here is not define the solution in detail but to highlight the major processes that need to be applied to deliver the data product. In future phases, if the data product is taken forward into implementation, more detailed analysis and design may decompose this data product idea down into a number of inter-linked data products (a data mesh). But for now, the key is to identify the major activities. Focus on highlighting activities that will require specific skills, methods and technology platforms to deliver.</p>
<h3 id="data-skills-tools-and-methods">Data Skills, Tools and Methods</h3>
<p>Outlines the people, technologies and processes that to enable the data processing (defined above) and to sustain the data product. This will include:</p>
<ul>
<li>Expertise - knowledge, technical skills and other domain related skills required to build, own and operate the data product.</li>
<li>Tools - the specific platforms, technologies, packages and libraries that will be required to support data processing.</li>
<li>DataOps - platforms, tools and practices related to addressing the non-functionality requirements associated with the data product such as testing, observability and source control.</li>
<li>Governance - which policies and principles are relevant to this data product? This is a significant topic in its own right, but it is important to highlight anything that is critical or unique in some way to this data product. Examples include:
<ul>
<li>Specific regulations that apply.</li>
<li>The classification of the data - for example is it highly sensitive personal information? This will fundamentally shape the design of the solution and inflate the TCO!</li>
<li>Considerations around data retention.</li>
<li>Business continuity and disaster recovery requirements.</li>
</ul>
</li>
<li>Standards - identify the specific standards that should be adopted by the data product, specifically to enable it to support key features of a data product:
<ul>
<li>Re-use by making it addressable and accessible</li>
<li>Inter-operability with other data products</li>
<li>Versioning</li>
<li>Publishing for discovery</li>
</ul>
</li>
</ul>
<p>As your organisational maturity develops, you should increasingly find that the skills, tools and methods identified will be well established and available as "re-usable IP" that is a native part of the data platform(s) on which you build and operate data products. Where a new skill, tool or method is required, this is likely to present additional cost and risk to the data product.</p>
<h3 id="total-cost-of-ownership-tco">Total Cost of Ownership (TCO)</h3>
<p>Captures all anticipated costs associated with the data product over its entire lifetime. In our experience, data products are likely to have a lifetime of at least 5 years and will incur a significant costs over this time. Some of this cost will be direct and tangible, other costs will be less tangible, but just as important to capture.</p>
<div class="aside"><p>For every £/$/€ spent building a data product, organizations typically go on to spend 5X of that maintaining it over its lifetime. Comprehensive TCO analysis prevents painful surprises.</p>
</div>
<p>For a deeper dive into TCO, we recommend our series <a href="https://endjin.com/blog/what-is-total-cost-of-ownership-why-is-it-important">What is total cost of ownership and why is it important?</a></p>
<p>There are many data teams out there, weighed down by the effort required to sustain legacy data products that are not delivering sufficient value or displacing the bandwidth that could be used to build, own and operate high impact / impact data products. If they had used a tools such as the data product canvas, or retrospectively applied it to identify data products that should now be retired, they could be maximising their contribution to the organisation.</p>
<p>Total cost of ownership is a broad topic but should consider:</p>
<ul>
<li>Infrastructure and storage costs</li>
<li>Build costs whether the costs are internal resources or outsourced</li>
<li>Support, maintenance and operations, with a view to staying on top of technical debt</li>
<li>Ongoing evolution to add new features</li>
<li>License fees</li>
<li>Data costs - such as paying for external data services</li>
<li>Training and documentation, including bandwidth necessary to keep up with the ever evolving cloud data platform landscape</li>
</ul>
<h2 id="strategic-principles-for-success">Strategic Principles for Success</h2>
<p>Now that we've covered the nine building blocks in detail, let's examine some key strategic principles that will help you get the most value from using the canvas in practice.</p>
<h3 id="keep-scope-as-narrow-as-possible">Keep scope as narrow as possible</h3>
<p>By design, the Data Product Canvas presents you with a small surface area to force you to focus the data product on a specific goal, audience and actionable insight.</p>
<p>By keeping the scope of the data product as targeted and as narrow in scope as possible, you are more likely to deliver it. It helps you to avoid the trap of monolithic solutions, "analysis paralysis" and multi-year waterfall projects that promise the earth, are often late to deliver and fail to meet their promise.</p>
<div class="aside"><p>If you can't fit your idea on a single canvas, it's too big. Break it down into smaller, more targeted data products that can be delivered incrementally.</p>
</div>
<p>Establishing <strong>clarity of purpose</strong> is fundamental in product design as it:</p>
<ul>
<li>Promotes alignment and effectively decision-making within the team responsible for building the product, helping you to avoid scope creep and un-wanted features</li>
<li>Clear communication helps with "marketing" the product by allowing users to grasp what the product is for</li>
<li>Protect the longevity of the product by simplifying the strategy and road map</li>
</ul>
<p>If you can't fit your idea on the canvas it's too big. Use the canvas to simplify your initial idea down to the quantum of functionality that will have maximum impact with the least amount of complexity and risk.</p>
<h3 id="tackle-data-products-as-a-socio-technical-endeavour">Tackle Data Products as a socio-technical endeavour</h3>
<p>To succeed, you need to master the cultural, organisational and human aspects as well as the technology in order to be successful in extracting value from data. We often talk about about <strong>people, process and technology</strong>. As data professionals, we often pay too much attention to the technology part. The data product canvas will help give you early sight of the people, process and technology barriers that may act against the goal(s) you are trying to achieve.</p>
<p>The key to winning hearts and minds is to involve all relevant stakeholders early in the lifecycle, to collaboratively build shared situational awareness, to be transparent about the people and process aspects that are key to success; and to get commitment from all to the transformation. The Data Model Canvas is a tool which supports this approach.</p>
<p>The latest wave of Generative AI is particularly susceptible to this. We have seen examples in the work we have been doing with clients to embrace the latest wave of Generative AI products and services: only the teams that are willing to develop new skills and embrace new ways of working are able to generate value from this exciting new technology.</p>
<p>We find the level of the resistance is often in proportion to that of the scale of transformation required to successfully adopt the new technology. This resistance is often driven by mis-aligned incentives. To quote Upton Sinclair:</p>
<blockquote>
<p>"It is difficult to get a person to understand something, when their salary depends upon them not understanding it."</p>
</blockquote>
<h3 id="elevator-pitch">Elevator pitch</h3>
<p>The Data Product Canvas provides all of the information you need for your <a href="https://en.wikipedia.org/wiki/Elevator_pitch">elevator pitch</a>. It covers all of the fundamentals: the purpose (what problem is it solving?), who it's target users are and the value it is seeking to generate.</p>
<p>It is extremely useful when you are required to "sell" the idea to senior stakeholders who may need to be convinced to committing budget and / or resources to it. It is the mental model you can use to describe the data product. If you do choose to share with the stakeholder, we wouldn't recommend walking through it line by line. Describe the principle of the canvas and allow the stakeholder to then explore it and ask questions. You may be surprised to find that they have already come across the Business Model Canvas or simply "grok it".</p>
<h2 id="conclusion">Conclusion</h2>
<p>The Data Product Canvas transforms how organizations approach data products by shifting focus from technology-first to purpose-driven design. By working through these nine building blocks in a collaborative, iterative process, teams can identify winning opportunities, eliminate costly missteps, and ensure alignment between business value and technical implementation. This upfront investment of time saves organizations substantial resources by validating ideas before committing to development. Start using the canvas today to dramatically improve how you conceptualize, communicate, and deliver data products.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Data Product Canvas</h3>
        <span class="series-toc__count">4 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/the-data-product-canvas-stop-building-products-that-fail" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Introducing The Data Product Canvas</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">2.</span>
                <span class="series-toc__part-title">Deep Dive into the Building Blocks</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/the-data-product-canvas-in-action" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">The Canvas in Action</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/the-data-product-canvas-theory-behind-the-canvas" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">The Theory Behind The Canvas</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>The Data Product Canvas: Stop Building Data Products That Fail</title>
      <description>Turn data initiatives into business success stories with the Data Product Canvas. This practical framework helps teams design data products that deliver real value, avoid common pitfalls, and align with business objectives.</description>
      <link>https://endjin.com/blog/the-data-product-canvas-stop-building-products-that-fail</link>
      <guid isPermaLink="true">https://endjin.com/blog/the-data-product-canvas-stop-building-products-that-fail</guid>
      <pubDate>Fri, 17 Oct 2025 05:30:00 GMT</pubDate>
      <category>Data Product Canvas</category>
      <category>Business Model Generation</category>
      <category>Data Mesh</category>
      <category>Data Product</category>
      <category>Value Proposition</category>
      <category>User Centred Design</category>
      <category>Pivot</category>
      <category>Fail Fast</category>
      <category>Purpose Driven Design</category>
      <category>Data</category>
      <category>Analytics</category>
      <category>Microsoft Fabric</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/05/the-data-product-canvas-stop-building-products-that-fail.png" />
      <dc:creator>Barry Smart</dc:creator>
      <content:encoded><![CDATA[<p>TLDR; The Data Product Canvas adapts the Business Model Canvas and Data Mesh frameworks to help teams collaboratively envisage, evaluate and iterate on a data product idea before committing significant resources to it.</p>
<p>Data products often fail not due to technical limitations, but because they don't solve real business problems or aren't operationally sustainable.</p>
<p>The symptoms are:</p>
<ul>
<li><strong>Misalignment</strong> - solutions looking for problems rather than addressing real pain points, analytics that don't drive actual decisions or actions, insights that arrive too late to be valuable.</li>
<li><strong>High costs, low value</strong> - high maintenance costs for low-value solutions, expensive licenses for underutilized tools.</li>
<li><strong>Strategic stagnation</strong> - businesses that are unable to respond quickly to market changes, unable to capitalize on AI/ML advances and falling behind the competition.</li>
<li><strong>Lack of engagement</strong> - users reverting to old ways of working, lack of skills and incentives to effectively use new tools, unclear ownership and accountability.</li>
</ul>
<p>In our experience, success is most often driven by the following factors, listed here in descending order of impact:</p>
<ol>
<li><strong>Clear Value Proposition</strong> - data products with a clear purpose.</li>
<li><strong>Quality Data</strong> - solid data foundations with active stewardship.</li>
<li><strong>Leadership</strong> - who set the vision, drive the roadmap and inspire change.</li>
<li><strong>Stakeholder Alignment</strong> - key decision-makers and influencers are united behind the vision.</li>
<li><strong>Champions</strong> - who lead by example when it comes to adoption of new tools and ways of working.</li>
<li><strong>Change Support</strong> - training and process redesign smooths the path from old to new ways of working.</li>
<li><strong>User-First Design</strong> - products that are accessible to all, simple to master, and impossible to live without.</li>
<li><strong>ROI Impact</strong> - a clear understanding of the lifetime costs relative to lifetime value.</li>
<li><strong>Technical Expertise</strong> - access to the skills and knowledge required to design, build and sustain a data product over its lifetime.</li>
<li><strong>Technical Feasibility</strong> - access to the technologies required to develop the product.</li>
</ol>
<p>The path to success is clear: start with purpose, build on quality data, and create an environment where users thrive. Get these right, and technical implementation becomes the easier part of the journey.</p>
<h2 id="introducing-the-data-product-canvas">Introducing the data product canvas</h2>
<p>The Data Product Canvas is a blueprint for success - a simple yet powerful tool that aims to bring all of the stakeholders together to shape data products that deliver real value.</p>
<p>It does this by presenting 9 simple building blocks that will encourage you to think holistically about the data product.</p>
<ul>
<li><strong>Audience</strong> - the specific groups of people that the data product is aiming to create value for.</li>
<li><strong>Actionable Insight</strong> - the data driven actionable intelligence that will be delivered by the data product to allow the audience to achieve a specific goal.</li>
<li><strong>Consumption</strong> - the means through which the audience will access and use the data product.</li>
<li><strong>Adoption</strong> - the support that will be given to the audience to enable them to successfully discover and use the data product.</li>
<li><strong>Lifetime Value</strong> - the value (tangible and intangible) that the data product is aiming to deliver over its lifetime.</li>
<li><strong>Data Sources</strong> - the data sources which are required to deliver the actionable insight.</li>
<li><strong>Data Processing</strong> - the actions that will need to be taken to transform data sources into the actionable insight.</li>
<li><strong>Data Skills, Tools and Methods</strong> - the key capabilities that will be required to deliver and sustain the data product over its lifetime.</li>
<li><strong>Total Cost of Ownership</strong> - the projected costs to design, build, test, operate, maintain and evolve the data product over its lifetime.</li>
</ul>
<p><a target="_blank" href="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/01/endjin-data-product-canvas.png"><img src="https://endjin.com/assets/images/blog/2025/01/endjin-data-product-canvas.png" alt="Image of the Data Product Canvas developed by endjin inspired by Business Model Generation and Data Mesh" title="Image of the Data Product Canvas"></a></p>
<p>If you would like an Adobe Acrobat version of the canvas, please <a href="https://endjin.com/contact-us/">reach out to us</a>.</p>
<p>The Data Product Canvas draws inspiration from two areas of thought leadership:</p>
<ul>
<li><a href="https://www.strategyzer.com/library/the-business-model-canvas">Business Model Canvas</a> - a visual tool for designing, exploring and iterating on new strategies and product ideas.</li>
<li><a href="https://endjin.com/blog/microsoft-fabric-is-a-socio-technical-endeavour">Data Mesh</a> - specifically the concept of "data as a product", which is one of the 4 key principles applied in a Data Mesh architecture.</li>
</ul>
<h2 id="feedback-loops">Feedback Loops</h2>
<p>The super power of this canvas is that it enables positive feedback loops: one of the unsung strengths of the Agile approach.</p>
<p>The canvas is primarily intended for use early in the lifecycle before any effort or budget is consumed on implementing the data product.  It achieves this by holding a mirror up those who are considering the idea, with the objective of allowing them to dispassionately evaluate the feasibility and to highlight in advance the key barriers they will need to overcome to make the data product a success.</p>
<p>The process of completing the canvas is iterative in nature.  It will require you to conduct short conversations with different stakeholders, with each conversation allowing you to illuminate a specific area of the canvas, uncover new information or raise questions that require you loop back and re-consider areas of the canvas you may already have populated.</p>
<p>At some point in completing the canvas, you may need to fundamentally re-think the idea (a practice often referred to as a "pivot") or decide that the idea simply isn't feasible (i.e. to "fail fast").  Either is a positive outcome, as it is better to do this whilst the idea is still on paper rather than further down the lifecycle when significant budget has been expended!</p>
<div class="aside"><p>The cheapest thing you can do it talk, the most expensive thing you can do is code!</p>
</div>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/01/data-product-lifecycle-pivot-fail-fast.png" alt="The product lifecycle showing the idea, explore, validate, grow, sustain and retire phases" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/01/data-product-lifecycle-pivot-fail-fast.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/01/data-product-lifecycle-pivot-fail-fast.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/01/data-product-lifecycle-pivot-fail-fast.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/01/data-product-lifecycle-pivot-fail-fast.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<h2 id="looking-ahead-making-data-products-work">Looking Ahead: Making Data Products Work</h2>
<p>The technology landscape has transformed dramatically in the last decade.  The pandemic forced many organisations to make decisions from data rather than from gut feel face-to-face interactions.  The more recent emergence of Generative AI has sparked fresh enthusiasm for data-driven transformation.</p>
<p>The key challenge is choosing the right opportunities and understanding what is necessary to implement them successfully as a <a href="https://endjin.com/blog/data-is-a-socio-technical-endeavour">socio-technical endeavour</a>.  Organisations face several critical questions:</p>
<ul>
<li>How do we prioritize competing ideas for data products?</li>
<li>Where will our investments have the most significant impact?</li>
<li>How do we ensure successful implementation?</li>
<li>How do we align stakeholders and resources effectively?</li>
<li>Do we have the necessary skills and knowledge to deliver this on time, within budget?</li>
</ul>
<p>The Data Product Canvas offers a framework to answer these questions. By encouraging you to take a collaborative approach to envisioning, evaluating, and iterating on data product ideas, it helps teams:</p>
<ul>
<li>Identify high-impact opportunities</li>
<li>Anticipate implementation challenges early</li>
<li>Consider cultural and organizational factors</li>
<li>Build stakeholder alignment</li>
<li>Make informed investment decisions</li>
</ul>
<p>Organizations today face a critical disconnect: soaring ambitions for AI and data transformation, but earthbound budgets and resources to achieve them. It's the classic "champagne ambitions versus sparkling water budget" dilemma, but with high-stakes consequences for business competitiveness.</p>
<p>Without a structured approach to evaluating data initiatives, organisations risk wasting limited time, resources on budget on failed data products.  The Data Product Canvas is a useful tool for preventing expensive mistakes and identifying winners before you invest. In an era where every failed data project means lost market share and competitive advantage, organizations can't afford the traditional "build it and they will come" approach.</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Data Product Canvas</h3>
        <span class="series-toc__count">4 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">1.</span>
                <span class="series-toc__part-title">Introducing The Data Product Canvas</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/the-data-product-canvas-deep-dive-into-building-blocks" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Deep Dive into the Building Blocks</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/the-data-product-canvas-in-action" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">The Canvas in Action</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/the-data-product-canvas-theory-behind-the-canvas" class="series-toc__link">
                    <span class="series-toc__part-number">4.</span>
                    <span class="series-toc__part-title">The Theory Behind The Canvas</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Building data quality into Microsoft Fabric</title>
      <description>Data quality issues are one of the biggest silent killers of analytics initiatives. This post explores how to build data quality into Microsoft Fabric from the ground up.</description>
      <link>https://endjin.com/blog/building-data-quality-into-microsoft-fabric</link>
      <guid isPermaLink="true">https://endjin.com/blog/building-data-quality-into-microsoft-fabric</guid>
      <pubDate>Wed, 15 Oct 2025 23:00:00 GMT</pubDate>
      <category>Microsoft Fabric</category>
      <category>Data Quality</category>
      <category>Data Governance</category>
      <category>Data Engineering</category>
      <category>Data Validation</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/09/building-data-quality-into-microsoft-fabric.png" />
      <dc:creator>Mike Evans-Larah</dc:creator>
      <content:encoded><![CDATA[<p>Data quality issues are one of the biggest silent killers of analytics initiatives. Teams invest significant time and resources into building dashboards and reports, only to discover their data pipeline was feeding them incorrect information all along.</p>
<h2 id="the-hidden-cost-of-poor-data-quality">The hidden cost of poor data quality</h2>
<p>We’ve seen this play out in many organizations: ETL jobs run without errors, reports refresh on schedule, and stakeholders confidently act on the data in front of them - until someone takes a closer look and uncovers a critical flaw in the underlying data.</p>
<p>The impact goes beyond just incorrect numbers. Poor data quality erodes trust in your analytics platform, creates expensive firefighting exercises, and can lead to costly business decisions based on faulty information.</p>
<p>We've learned that traditional approaches to data quality - where you build first and validate later - simply don't work. By the time you discover quality issues, they've already propagated through your entire analytics ecosystem.</p>
<h2 id="a-validation-first-approach">A validation-first approach</h2>
<p>The solution isn't just better testing (although testing forms an important part of the story too - see James Broome's talk on <a href="https://endjin.com/what-we-think/talks/how-to-ensure-quality-and-avoid-inaccuracies-in-your-data-insights">How to ensure quality and avoid inaccuracies in your data insights</a>) - it's rethinking how to approach data quality from the ground up. We use a "validation-first" mindset, where data quality checks become first-class citizens in the pipeline design.</p>
<p>Here are four strategic principles we follow when building data quality into Fabric implementations:</p>
<h2 id="principle-1-validate-early-and-often-in-your-pipeline-design">Principle 1: Validate early and often in your pipeline design</h2>
<p>The earlier you catch data quality issues, the cheaper they are to fix. We weave validation checks throughout the entire data pipeline.</p>
<p>In Fabric, this means building validation logic directly into your data engineering pipelines using notebooks and dataflows. Instead of waiting until data reaches your lakehouse, we validate at multiple checkpoints:</p>
<ul>
<li><strong>Source validation</strong>: Check data quality as it enters Fabric from external systems</li>
<li><strong>Transformation validation</strong>: Verify data integrity after each major transformation step</li>
<li><strong>Business rule validation</strong>: Ensure data meets your organization's specific requirements</li>
<li><strong>Output validation</strong>: Final checks before data reaches consumption layers</li>
</ul>
<p>This multi-layered approach aligns well with <a href="https://endjin.com/what-we-think/talks/microsoft-fabric-lakehouse-and-medallion-architecture">medallion architecture</a> principles, where data quality is maintained at each stage of the data lifecycle. For a deep-dive of how this can look in practice, see <a href="https://endjin.com/blog/creating-quality-gates-in-the-medallion-architecture-with-pandera">Creating Quality Gates in the Medallion Architecture with Pandera</a>.</p>
<h2 id="principle-2-build-rich-actionable-error-reporting-from-the-start">Principle 2: Build rich, actionable error reporting from the start</h2>
<p>Generic error messages like "data validation failed" are useless when an urgent issue arises in production. We design our validation systems to provide rich, contextual information that helps teams quickly identify and resolve problems.</p>
<p>This means building validation reports that are user-friendly and include details like: specific error descriptions, which records are problematic and why, and suggested next steps for resolution.</p>
<p>These can be shared in the form of HTML-based emails, Power BI reports, or custom web applications, depending on the needs of the team.</p>
<div class="aside"><p><strong>NOTE</strong>: Be wary to ensure that any sensitive information is appropriately redacted when sharing validation reports.</p>
</div>
<h2 id="principle-3-create-feedback-loops-that-teams-actually-use">Principle 3: Create feedback loops that teams actually use</h2>
<p>The best validation system in the world is worthless if teams ignore the alerts. We design feedback systems that integrate naturally into how teams already work.</p>
<p>Alerts should arrive in the right channel at the right time (and be directed at the right people). Combined with rich, contextual information in the validation reports, this ensures that teams have everything they need to take action.</p>
<p>Fabric's integration with <a href="https://learn.microsoft.com/en-us/fabric/data-factory/teams-activity">Microsoft Teams</a> and <a href="https://learn.microsoft.com/en-us/fabric/data-factory/outlook-activity">email systems</a> makes it easy to create notification workflows that fit into existing team communication patterns.</p>
<h2 id="principle-4-integrate-quality-metrics-into-your-monitoring-strategy">Principle 4: Integrate quality metrics into your monitoring strategy</h2>
<p>Data quality shouldn't be an afterthought in your monitoring approach - it should be a core metric alongside performance and availability. Validation results should be captured and analyzed just like any other operational metric.</p>
<p>Fabric's <a href="https://learn.microsoft.com/en-us/fabric/fundamentals/workspace-monitoring-overview">built-in workspace monitoring capabilities</a>, combined with Power BI dashboards, enable teams to track data quality metrics alongside other operational KPIs and visualize trends over time.</p>
<p>Steps can be taken to automate the collection and reporting of these metrics, ensuring that data quality remains a top priority.</p>
<h2 id="the-results">The results</h2>
<p>Implementing these principles in Fabric environments, you can expect:</p>
<ul>
<li><strong>Faster issue resolution</strong>: Teams can identify and fix data problems in minutes rather than hours or days</li>
<li><strong>Increased stakeholder confidence</strong>: Business users trust the data because they see consistent quality</li>
<li><strong>Reduced firefighting</strong>: Fewer emergency meetings about "why the numbers are wrong"</li>
</ul>
<p>Perhaps most importantly, teams can feel more confident about their analytics outputs. When you know your data quality processes are robust, you can focus on deriving insights rather than constantly second-guessing your numbers.</p>]]></content:encoded>
    </item>
    <item>
      <title>Top Features of Notebooks in Microsoft Fabric</title>
      <description>Lakehouse integration, built-in notebook resources, and collaboration features that set Microsoft Fabric notebooks apart from Jupyter and Databricks.</description>
      <link>https://endjin.com/blog/top-features-of-notebooks-in-microsoft-fabric</link>
      <guid isPermaLink="true">https://endjin.com/blog/top-features-of-notebooks-in-microsoft-fabric</guid>
      <pubDate>Wed, 15 Oct 2025 06:30:00 GMT</pubDate>
      <category>Microsoft Fabric</category>
      <category>Notebooks</category>
      <category>OneLake</category>
      <category>Delta Lake</category>
      <category>Spark</category>
      <category>Lakehouse</category>
      <category>DuckDB</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/09/top-features-of-notebooks-in-microsoft-fabric.png" />
      <dc:creator>Jessica Hill</dc:creator>
      <content:encoded><![CDATA[<p>The notebook experience in Microsoft Fabric is similar to notebook experiences on other platforms - they provide an interactive and collaborative environment where you can combine code, output, and documentation for data exploration and processing. However, there are a number of key features that set Fabric notebooks apart. I'll walk through the top features of Fabric notebooks in this blog post.</p>
<h2 id="native-integration-with-lakehouses">Native Integration with Lakehouses</h2>
<p>Fabric notebooks are natively integrated with your lakehouses in Fabric. You can mount a new or existing lakehouse directly into your Fabric notebook simply by using the 'Lakehouse Explorer' in the notebook interface. The Lakehouse Explorer automatically detects all of the tables and files stored within your lakehouse which you can then browse and load directly into your notebook. This direct integration with your lakehouses eliminates any need for manual paths / set-up, making it simple and intuitive to explore your lakehouse data from your Fabric notebook.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/09/lakehouseexplorer.png" alt="Lakehouse Explorer in Fabric Notebooks." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/09/lakehouseexplorer.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/09/lakehouseexplorer.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/09/lakehouseexplorer.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/09/lakehouseexplorer.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<h2 id="built-in-file-system-with-notebook-resources">Built-in File System with Notebook Resources</h2>
<p>Fabric notebooks also come with a built-in file system called notebook 'Resources', allowing you to store small files - like code modules, CSVs and images etc. The notebook 'Resources Explorer' acts like a local file system within the notebook environment, you can manage folders and files here just like you would on your local machine. Within your notebook, you can then read from or write to the built-in file system. The files stored in the file system are tied to the notebook itself, and are separate from OneLake. This is useful for when you want to store files temporarily to perform quick experiments or ad hoc analysis of data / scripts. Or, if you want to just simply store notebook-specific assets.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/09/resourcesexplorer.png" alt="Notebook Resources in Fabric Notebooks." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/09/resourcesexplorer.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/09/resourcesexplorer.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/09/resourcesexplorer.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/09/resourcesexplorer.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<h2 id="drag-and-drop-data-exploration-with-data-wrangler">Drag-and-Drop Data Exploration with Data Wrangler</h2>
<p>Fabric notebooks also have a built in feature called the 'Data Wrangler' which allows you to use the notebook interface to drag-and-drop files from your lakehouse / in-built file system into your notebook and load the data, all without writing any code. After dropping the file into the notebook, the data wrangler autogenerates the code needed to query and load the data. This low-code experience, simplifies data loading and lowers the barrier to entry to get started with data exploration. You don't need any coding experience to simply just load your data into your Fabric notebook.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/09/datawrangler.gif" alt="Drag and Drop with Data Wrangler in Fabric Notebooks." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/09/datawrangler.gif 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/09/datawrangler.gif 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/09/datawrangler.gif 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/09/datawrangler.gif 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<h2 id="ai-assistance-with-copilot">AI Assistance with Copilot</h2>
<p>Copilot for Data Science and Data Engineering (Preview) is an AI assistant within Fabric notebooks that helps you to analyse and visualise your data. You can ask Copilot to provide insights on your data, generate code for data transformations or to build visualisations.</p>
<p>In Fabric notebooks, you can access Copilot by using the Copilot chat panel. Here you can ask questions like "Show me the top 10 products by sales", "Show me a bar chart of sales by product" or "Generate code to remove duplicates from this dataframe". Copilot will respond with either natural language explanations or will generate the relevant code snippets that you can copy and paste into your notebook to execute. You can also ask the Copilot chat to provide natural language explanations of notebook cells, and add markdown comments, helping you to understand and document your code. This makes data exploration more accessible, especially for those with a lower level of coding knowledge.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/09/copilotchat.png" alt="Copilot chat panel in Fabric Notebooks." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/09/copilotchat.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/09/copilotchat.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/09/copilotchat.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/09/copilotchat.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>Alongside the Copilot chat, you can also interact with Copilot directly within your notebook cells by using the Copilot in-cell panel. Here you can make requests to Copilot and it will provide the necessary code snippet directly in the cell below.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/09/copilotpanel.gif" alt="Copilot in cell panel in Fabric Notebooks." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/09/copilotpanel.gif 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/09/copilotpanel.gif 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/09/copilotpanel.gif 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/09/copilotpanel.gif 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>Also built into Fabric notebooks is Copilot's AI-driven inline code completion. Copilot generates code suggestions as you type based on your notebook's context using a model trained on millions of lines of code. This feature minimises syntax errors and helps you to write code more efficiently in your notebooks, accelerating notebook development.</p>
<p>You can also use Copilot to add comments, fix errors, or debug your Fabric notebook code by using <a href="https://learn.microsoft.com/en-us/fabric/data-engineering/copilot-notebooks-chat-magics">Copilot's chat-magics</a>. These are a set of IPython magic commands, that help you to interact with Copilot. For example, placing the <code>%%add_comments</code> magic command above a cell prompts Copilot to annotate the code with a meaningful explanation. Similarly, the <code>%%fix_errors</code> command analyses code and suggests corrections inline.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/09/copilotchatmagics.gif" alt="Copilot chat magics." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/09/copilotchatmagics.gif 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/09/copilotchatmagics.gif 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/09/copilotchatmagics.gif 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/09/copilotchatmagics.gif 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>Having spent time working with Copilot in Fabric notebooks, I've found the main advantage is that is that it saves time. Even if the output needs tweaking, it saves time and effort upfront by doing the bulk of the ground work. This is especially true for tasks that don't require deep contextual understanding or complex decision-making, for example reading/writing data, creating schemas from dataframes, renaming columns, basic transformations etc. Or for tasks where is already a pattern in place within your code base, as you can ask Copilot to base its output on that, and it's generally accurate. I've also found that it's good at debugging your code and can spot things that are not always obvious, and it's pretty good at generating documentation too, all of which also saves time and effort.</p>
<p>However, Copilot doesn't always fully understand the context or intent behind your work. This is especially true for more complex tasks. Or, sometimes it might suggest code that is unoptimised. This is why you can't be fully reliant on Copilot's suggestions, you still need to review and refine what it generates. That said, even if the output isn't exactly what you need, it is often along the right tracks and can give you inspiration to get started. On the whole it's useful to get unblocked and speed up routine tasks, but it should be used as a tool to assist you, whilst you stay in control of the decision making behind your code. It is also worth noting that most of the Fabric notebooks Copilot features described above are currently in Preview.</p>
<h2 id="faster-spark-start-up">Faster Spark Start-Up</h2>
<p>With the Spark-based Fabric notebooks, it is generally very quick to spin up a Spark session. If you have ever used notebooks in Azure Synapse before, you will know it takes a few minutes to spin up a Spark session. However, with Fabric notebooks, it takes a matter of seconds. The fast start up times for Spark sessions is due to Fabric's starter pool model, which keeps a lightweight Spark runtime ready to serve new sessions. This means when you initiate a Spark job, it can attach your session to an already running pool and it doesn't need to provision a full cluster from scratch.</p>
<p>If you're running Spark sessions anywhere else in your tenant, the Spark runtime should start very quickly. This is because Fabric re-uses active sessions across the tenant. If any Spark session is already active within your tenant, your notebook can essentially piggyback on that runtime, allowing it to start in seconds. However, it is worth noting that if it's the first time you've run a Spark job in a while, it will take slightly longer to spin up a Spark session.</p>
<h2 id="python-notebooks">Python Notebooks</h2>
<p>Python notebooks in Fabric offer a pure Python coding environment without Spark. They run on a single-node cluster (2 vCores / 16 GB RAM) making them a cost-effective tool for processing small to medium sized datasets where distributed computing is not required. Using the Apache Spark engine for small datasets can get expensive and is often overkill. Depending on your workload size and complexity, Python Notebooks in Fabric may be a more cost-efficient option than using the Spark-based notebook experience in Fabric.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/09/pythonnotebooks.png" alt="Python Notebooks in Fabric." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/09/pythonnotebooks.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/09/pythonnotebooks.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/09/pythonnotebooks.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/09/pythonnotebooks.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>The Python notebook runtime comes pre-installed with libraries like <a href="https://delta-io.github.io/delta-rs/">delta-rs</a> and <a href="https://duckdb.org/">DuckDB</a> (See <a href="https://endjin.com/who-we-are/our-people/barry-smart/">Barry Smart's</a> 3-part <a href="https://endjin.com/blog/duckdb-rise-of-in-process-analytics-understanding-data-singularity">series on DuckDB</a>) for reading and writing Delta Lake data, as well as the Polars and Pandas libraries for fast, data manipulation and analysis. This environment is ideal for those who want to leverage these libraries without additional setup. These libraries are not available by default in PySpark notebooks in Fabric, meaning that you would need to manually install and configure them to access similar functionality. For workflows that benefit from these specific libraries, Python notebooks offer a more ready-to-go experience.</p>
<h2 id="integration-with-power-bi-semantic-models-through-semantic-link">Integration with Power BI Semantic Models through Semantic Link</h2>
<p>The final key feature of Fabric notebooks that this blog post is going to cover is their integration with Power BI semantic models through Semantic Link. Semantic Link is a feature in Fabric that connects Power BI semantic models with Fabric notebooks. It enables the propagation of semantic information - like relationships and hierarchies from Power BI into Fabric notebooks.</p>
<p>Fabric notebooks also have access to Semantic Link Labs, which is an open-source Python library built on top of Semantic Link, which contains over 450 functions that enable you to programmatically manage semantic models and Power BI reports all from within Fabric notebooks. You can do things like rebinding reports to new models, detecting broken visuals, saving reports as <code>.pbip</code> files for version control or deploying semantic models across multiple workspaces with consistent governance.</p>
<p>Python notebooks in Fabric also offer support for the SemPy library. This is another Python library built on top of Semantic Link that enables you to interact with Power BI semantic models using pandas-like operations (but it's not actually pandas under the hood). SemPy introduces a custom object called <code>FabricDataFrame</code>, which behaves similarly to a pandas dataframe but it is semantically enriched. This means it carries metadata from Power BI semantic models - like relationships, hierarchies, and column descriptions. SemPy supports operations like slicing, merging and concatenating dataframes whilst preserving these semantic annotations. This means that you can explore and transform your data, with semantic awareness maintained.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/09/sempyconnectingtosemanticmodel.png" alt="Using the SemPy library in Fabric Notebooks to connect to Power BI semantic models." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/09/sempyconnectingtosemanticmodel.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/09/sempyconnectingtosemanticmodel.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/09/sempyconnectingtosemanticmodel.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/09/sempyconnectingtosemanticmodel.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>Another key feature of the SemPy library is the ability to retrieve and evaluate DAX measures from your Power BI semantic models. For example, you can use SemPy to retrieve DAX measures like "Total Sales" from your semantic model. Similarly, with SemPy, you can also write new DAX expressions and evaluate them within your notebook.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/09/sempyevaluatingdax.png" alt="Using the SemPy library in Fabric Notebooks to retrieve DAX measures and write DAX expressions." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/09/sempyevaluatingdax.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/09/sempyevaluatingdax.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/09/sempyevaluatingdax.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/09/sempyevaluatingdax.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>This means you can use business logic, like calculated KPIs or aggregations, defined in Power BI directly in your notebooks, without needing to reimplement the logic manually in Python. Using business logic already defined in Power BI, directly in your Fabric notebooks, reduces duplication and ensures consistency. It also promotes collaboration between data scientists working in Fabric notebooks and business analysts working in Power BI - as both are using a shared semantic layer.</p>
<p>Note that all notebook experiences in Fabric support Semantic Link, but only the Python notebook experience in Fabric offers support for the SemPy library.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Fabric notebooks offer a lot of great features. You can access your lakehouse data easily through the Lakehouse Explorer and store your notebook-specific assets in the built-in file system. The drag-and-drop experience with Data Wrangler makes data exploration accessible, and Copilot provides AI assistance for writing, documenting, and debugging code as well as generating insights from your data. Spark sessions are quick to start up due to Fabric's starter pool model, which makes distributed processing faster than platforms like Azure Synapse.</p>
<p>Python notebooks provide a lightweight, cost-effective alternative for smaller workloads, and come pre-installed with libraries like Polars, DuckDB, and delta-rs - providing a ready-to-use environment for your analytics. Finally, the integration with Power BI semantic models through Semantic Link, Semantic Link Labs, and SemPy allow you to interact with semantic models programmatically, apply DAX measures directly in notebooks, and maintain semantic integrity across transformations. This shared semantic layer promotes collaboration between data scientists and business analysts, which ensures consistency and reduces duplication of logic across platforms.</p>
<p>Whilst these are the top features that stood out to me, there are also lots of other capabilities within Fabric notebooks, so do go and check them out yourself. My <a href="https://endjin.com/who-we-are/our-people/ed-freeman/">colleague Ed</a> has produced a great YouTube video series on getting started with Fabric notebooks, including <a href="https://www.youtube.com/watch?v=s_mHaLBlA94">Microsoft Fabric: Processing Bronze to Silver using Fabric Notebooks</a> and <a href="https://www.youtube.com/watch?v=UyS6ZUgh-Wc">Microsoft Fabric: Good Notebook Development Practices</a>.</p>]]></content:encoded>
    </item>
    <item>
      <title>How .NET 10.0 boosted JSON Schema performance by 18%</title>
      <description>Benchmarking Corvus.JsonSchema on .NET 10.0 showed an 18% free speed-up on top of .NET 8 and 9 gains — a cumulative 29% boost for LTS upgrades from .NET 8.</description>
      <link>https://endjin.com/blog/how-dotnet-10-boosted-json-schema-performance-by-18-percent</link>
      <guid isPermaLink="true">https://endjin.com/blog/how-dotnet-10-boosted-json-schema-performance-by-18-percent</guid>
      <pubDate>Mon, 13 Oct 2025 04:03:00 GMT</pubDate>
      <category>C# 14.0</category>
      <category>C# 14</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>.NET 10.0</category>
      <category>dotnet</category>
      <category>C# Tutorials</category>
      <category>C# Programming</category>
      <category>High Performance</category>
      <category>low-allocation</category>
      <category>ref struct</category>
      <category>Span&lt;T&gt;</category>
      <category>ReadOnlySpan&lt;T&gt;</category>
      <category>JSON Schema</category>
      <category>Parser</category>
      <category>Code Generation</category>
      <category>JSON Validation</category>
      <category>JSON Serialization</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/10/how-dotnet-10-boosted-json-schema-performance-by-18-percent.png" />
      <dc:creator>Matthew Adams</dc:creator>
      <content:encoded><![CDATA[<p>At endjin, we maintain <a href="https://github.com/corvus-dotnet/Corvus.JsonSchema/">Corvus.JsonSchema</a>, an open source <a href="https://endjin.com/blog/json-schema-patterns-dotnet-data-object">high-performance library for serialization and validation of JSON using JSON Schema</a>.</p>
<p>Its first release was on .NET 7.0, and its performance was pretty impressive. <a href="https://endjin.com/who-we-are/our-people/ian-griffiths/">Ian Griffiths</a> has given a <a href="https://endjin.com/what-we-think/talks/high-performance-json-serialization-with-code-generation-on-csharp-11-and-dotnet-7-0">number of talks</a> on the techniques it uses to achieve its performance goals.</p>
<p>Since then, the .NET 8.0 runtime shipped, and with no code changes at all, we got a "free" performance boost of ~20%.</p>
<p>And then, when .NET 9.0 shipped, we got a further 32% performance boost, with our new Corvus.JsonSchema V4 codebase.</p>
<p>Now, with the .NET 10.0 release candidate available, we can see another significant boost: ~18% "for free" just for switching out the runtime.</p>
<p>If you have a LTS-based upgrade cycle and you are switching from .NET 8 to .NET 10, you'll see a pretty amazing 29% improvement.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/assets/images/blog/2025/10/json-schema-performance.svg" alt="The Json Schema Performance Graph shows the benchmark results we will examine in the section below."></p>
<p>Here are the details.</p>
<h2 id="the-benchmark">The Benchmark</h2>
<p>We run the benchmark on a 13th Gen Intel Core i7-13800H 2.90GHz, 1 CPU, 20 logical and 14 physical cores.</p>
<p>The dotnet versions are as follows:
.NET 10.0.0 (10.0.0-rc.1.25451.107, 10.0.25.45207), X64 RyuJIT x86-64-v3
.NET 8.0.20 (8.0.20, 8.0.2025.41914), X64 RyuJIT x86-64-v3
.NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3</p>
<p>Validating a large array consisting of 10,000 small JSON documents. This is typical of a small JSON payload in a web API. It includes some strings, some formatted strings (e.g. email, date), and some numeric values.</p>
<pre><code class="language-json">{
    "name": {
        "familyName": "Oldroyd",
        "givenName": "Michael",
        "otherNames": [],
        "email": "michael.oldryoyd@contoso.com"
    },
    "dateOfBirth": "1944-07-14",
    "netWorth": 1234567890.1234567891,
    "height": 1.8
}
</code></pre>
<table>
<thead>
<tr>
<th>Method</th>
<th>Runtime</th>
<th style="text-align: right;">Mean</th>
<th style="text-align: right;">Ratio</th>
<th style="text-align: right;">Allocated</th>
</tr>
</thead>
<tbody>
<tr>
<td>ValidateLargeArrayCorvusV4</td>
<td>.NET 8.0</td>
<td style="text-align: right;">9.804 ms</td>
<td style="text-align: right;">1.00</td>
<td style="text-align: right;">-</td>
</tr>
<tr>
<td>ValidateLargeArrayCorvusV4</td>
<td>.NET 9.0</td>
<td style="text-align: right;">8.447 ms</td>
<td style="text-align: right;">0.86</td>
<td style="text-align: right;">-</td>
</tr>
<tr>
<td>ValidateLargeArrayCorvusV4</td>
<td>.NET 10.0</td>
<td style="text-align: right;">6.913 ms</td>
<td style="text-align: right;">0.71</td>
<td style="text-align: right;">-</td>
</tr>
</tbody>
</table>
<h3 id="notes-on-7.0">Notes on 7.0</h3>
<p>.NET 7.0 is no longer supported, and we have dropped it from the benchmarks.</p>
<h2 id="is-this-the-end-of-the-free-lunch">Is this the end of the free lunch?</h2>
<p>As always, we question whether we would be seeing the end of the free lunch. Last year, we figured that we would still get improvements from .NET 10 and we were right. I don't think we've reached the end of the road yet.</p>
<h3 id="looking-at-the-future">Looking at the future</h3>
<p>We are currently working on vNext of Corvus.JsonSchema. It has some significant changes in the code generator, and produces code which offers 2-10x improvement in validation efficiency. This is likely to land in the .NET 10 timeframe - and will no doubt take advantage of optimizations available in .NET 11!</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Json Schema Performance</h3>
        <span class="series-toc__count">3 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/how-dotnet-8-boosted-json-schema-performance-by-20-percent-for-free" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">.NET 8.0's 20% Boost</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/how-dotnet-9-boosted-json-schema-performance-by-32-percent" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">.NET 9.0's 32% Boost</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">3.</span>
                <span class="series-toc__part-title">.NET 10.0's 18% Boost</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Big Data London 2025</title>
      <description>AI agents dominated Big Data LDN 2025, but the real story wasn't the technology, it was which organisations could actually deploy it successfully. After five years tracking industry evolution through this event, one pattern emerged clearly: the winners had built their foundations first. For CTOs making platform decisions now, the strategic imperative isn't choosing between innovation and governance; it's recognizing that governance enables innovation at scale.</description>
      <link>https://endjin.com/blog/big-data-ldn-2025</link>
      <guid isPermaLink="true">https://endjin.com/blog/big-data-ldn-2025</guid>
      <pubDate>Thu, 09 Oct 2025 05:30:00 GMT</pubDate>
      <category>Data Mesh</category>
      <category>Data Product</category>
      <category>Data</category>
      <category>Analytics</category>
      <category>AI</category>
      <category>AI Agents</category>
      <category>Data Strategy</category>
      <category>Big Data London</category>
      <category>Microsoft Fabric</category>
      <category>MotherDuck</category>
      <category>DuckDB</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/10/big-data-ldn-2025.png" />
      <dc:creator>Barry Smart</dc:creator>
      <content:encoded><![CDATA[<p>TLDR; <a href="https://www.bigdataldn.com/">Big Data LDN</a> returned as the UK's largest free data and AI event, drawing 15,000+ professionals and 150+ exhibitors to Olympia in South West London. This year's event was dominated by AI agents.  Organizations demonstrating real AI success had done the foundational work first.  For senior leaders making platform decisions now, the message is clear: the strategic imperative isn't choosing between innovation and governance; it's recognizing that governance enables innovation at scale.</p>
<h2 id="the-industrys-evolution-five-years-in-focus">The Industry's Evolution: Five Years in Focus</h2>
<p>When I was a CTO, Big Data LDN was exactly the type of conference that I would make a point of attending.  With all of the major vendors represented at the event, it provides a rare opportunity to bring yourself up to date with the data and AI landscape.  CTOs and other senior decision makers continue to make important decisions about choice of platforms, architectures, methodology and skills for the future—this conference can really help to shape and validate those decisions.</p>
<p>One of the most fascinating aspects of attending Big Data LDN over the last 5 years is watching it serve as a barometer for where the industry's collective attention has shifted. Each annual edition captures a different phase in the evolution of the data landscape:</p>
<ul>
<li><strong>2021</strong>: the <a href="https://endjin.com/what-we-think/talks/what-is-a-data-lakehouse">Lakehouse</a> emerged as the solution to bridging data lakes and warehouses.  This platform along with the <a href="https://endjin.com/what-we-think/talks/microsoft-fabric-lakehouse-and-medallion-architecture">Medallion Architecture</a> became the foundation for unifying analytics and AI workloads.</li>
<li><strong>2022</strong>: <a href="https://endjin.com/what-we-think/talks/data-mesh-and-microsoft-fabric-a-perfect-fit">Data Mesh</a> dominated—how do we decentralize data ownership while maintaining coherence.  Data Products emerged as the organizing principle.  <a href="https://endjin.com/blog/big-data-ldn-highlights-and-how-to-survive-your-first-data-conference">Eli's blog provides deeper insights</a> into the 2022 event.</li>
<li><strong>2023</strong>: The ChatGPT revolution hit the conference hard.  The focus was very much on use of Generative AI to spearhead innovation.</li>
<li><strong>2024</strong>: The focus shifted to how organizations could leverage GenAI while building the proper foundations. A realisation that AI is only as smart as the data it's fed.</li>
<li><strong>2025</strong>: AI Agents took center stage promising new use cases and accelerating the democratisation of data and analytics.</li>
</ul>
<p>The progression tells a story. We've moved up the stack from infrastructure debates (where should data live?) through architectural patterns (how should we organize it?) to operational models (how should we manage it?) and now to intelligence layer (how should AI interact with it?).</p>
<p>But here's what's crucial: <strong>each layer builds on the previous ones</strong>. Organizations jumping straight to AI agents without solid infrastructure, governance, and product thinking will struggle.</p>
<div class="aside"><p>The success stories at this year's event all shared one characteristic: they had done the foundational work first.</p>
</div>
<h2 id="a-packed-event">A packed event</h2>
<p>Here are some photos to give you a sense of scale of the event.</p>
<p>The vast exhibition space houses 150+ vendor stands, each competing for attention. Multiple theatre spaces are curtained off but not soundproofed, creating a cacophony where live demonstrations blend with theatre presentations blend with hundreds of simultaneous conversations.
<img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/10/big-data-ldn-massive-hall.jpg" alt="Photo of one of the two halls filled with exhibitors and theatres." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/10/big-data-ldn-massive-hall.jpg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/10/big-data-ldn-massive-hall.jpg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/10/big-data-ldn-massive-hall.jpg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/10/big-data-ldn-massive-hall.jpg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>There were parallel tracks in progress across 17 theatres.  It was difficult to choose sometimes which session to attend!  Some theatres used noise cancelling headphones to help cut through the noise!
<img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/10/lightning-talk-theatre.jpg" alt="Picture of Barry Smart with noise cancelling headphones on at a session." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/10/lightning-talk-theatre.jpg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/10/lightning-talk-theatre.jpg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/10/lightning-talk-theatre.jpg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/10/lightning-talk-theatre.jpg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>Google's stand was very popular—you could design and print your own custom travel tag with the help of AI, there was also a lego builders competition with live commentary and final winner judged by an AI:
<img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/10/google-lego-builders-ai-judge.jpg" alt="Picture of Google's stand and specifically 4 contestants ready to go head to head in the lego builders challenge judged by an AI" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/10/google-lego-builders-ai-judge.jpg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/10/google-lego-builders-ai-judge.jpg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/10/google-lego-builders-ai-judge.jpg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/10/google-lego-builders-ai-judge.jpg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>Plan ahead to identify sessions you'd like to attend and exhibitors you'd like to visit. Popular sessions filled up 30 minutes early, so I pivoted to focus on the expo floor where I could control my experience. The expo floor offers interactions you can't replicate later, and the talks will be available on YouTube in 2-3 weeks. Subscribe to the <a href="https://www.youtube.com/@Bigdataldn">Big Data LDN YouTube channel</a> for notifications as these videos drop!
<img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/10/big-data-ldn-plan-your-sessions.jpg" alt="Picture of the paper programme (online version also available) with sessions marked" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/10/big-data-ldn-plan-your-sessions.jpg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/10/big-data-ldn-plan-your-sessions.jpg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/10/big-data-ldn-plan-your-sessions.jpg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/10/big-data-ldn-plan-your-sessions.jpg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<h2 id="agents-everywhere">Agents everywhere!</h2>
<p>If there was one dominant theme threading through Big Data LDN 2025, it was <strong>AI agents</strong>. But we need to distinguish between two very different approaches that signal fundamentally different bets about the future:</p>
<h3 id="the-integration-play">The Integration Play</h3>
<p>Most established vendors are following what we might call the "AI augmentation" strategy—integrating generative AI capabilities into existing platforms. The core product remains familiar, but now it has AI-powered assistance for common tasks.  This makes perfect sense for companies with well established workflows that are fundamentally sound and just need AI assistance. Why force users to learn entirely new paradigms when you can make their current work more intelligent?</p>
<h3 id="the-agent-first-approach">The Agent-First Approach</h3>
<p>More intriguing were the handful of new entrants building products that are <strong>fundamentally agentic from the ground up</strong>. These aren't traditional BI tools with ChatGPT bolted on—they're platforms designed around the assumption that AI agents will be the primary interface between users and data.</p>
<p>Companies like <a href="https://www.getlynk.ai/">Lynk AI</a> exemplify this approach. Rather than asking "how do we add AI to our existing product?", they're asking "if we put AI agents at the heart of the data value chain, what should the architecture look like?" This agent-first approach assumes AI will fundamentally restructure how we interact with data.</p>
<p>Whether these emerging companies can disrupt the established mega vendors remains to be seen.  But it was exciting to see the innovation and the focus being placed onto new capabilities which may not have had the emphasis historically.</p>
<h2 id="semantic-layer-the-foundation-for-agentic-analytics">Semantic Layer: The Foundation for Agentic Analytics</h2>
<p>This brings us to what might be the most important architectural conversation happening right now: <strong>the semantic layer</strong>.</p>
<p>The semantic layer sits between your curated data and your AI.  It provides the context and meaning over that data to help the AI navigate knowledge successfully to provide the right answer.</p>
<p>Without a robust semantic layer, AI agents are essentially guessing about what your data means. It's like asking someone to translate a document when they don't speak the language it's written in—they might produce something, but it won't be trustworthy.</p>
<p>The semantic layer enables:</p>
<ul>
<li><strong>Consistent definitions</strong> - What does "customer" mean within and across different domains?</li>
<li><strong>Business context</strong> - How do metrics relate to business outcomes?</li>
<li><strong>Governed logic</strong> - Ensuring AI agents use approved calculation methods such as how to derive "gross margin".</li>
<li><strong>Explainable results</strong> - Tracing how answers were generated, with supporting lineage and citations back to ground truth.</li>
</ul>
<p>Companies like <a href="https://www.getlynk.ai/">Lynk AI</a> are positioning this as the critical infrastructure for trustworthy agentic analytics. In our work with enterprise clients struggling to implement AI on messy data, this emphasis is spot-on.</p>
<h2 id="vendors-to-watch-innovation-beyond-the-mega-platforms">Vendors to watch: Innovation Beyond the Mega Platforms</h2>
<p>Three of the "big four" mega vendors (<a href="https://www.databricks.com/">Databricks</a>, <a href="https://www.snowflake.com/en/">Snowflake</a>, <a href="https://cloud.google.com/">Google</a>) had large spaces and were extremely busy.  They had the space to run their own demos and presentations.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/10/databricks-stand.jpg" alt="Picture of session under way at the Databricks stand" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/10/databricks-stand.jpg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/10/databricks-stand.jpg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/10/databricks-stand.jpg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/10/databricks-stand.jpg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>I was surprised to find that Microsoft, the final of the "big four", had no presence in the exhibition hall. Numerous exhibitors that partner with Microsoft had a presence.  But Microsoft themselves? Absent.  This is puzzling.</p>
<p>Microsoft had recently held their own Fabric Conference (FabCon) in Vienna: <a href="https://endjin.com/blog/fabcon-vienna-2025-day-1">my colleagues Carmel and Jess attended the event and wrote up a great summary</a>.  It's great that FabCon exists, but this is an event for people who are already committed to Fabric: they're willing to pay for the conference and will get a lot out of it.  Whereas Big Data LDN allows CTOs and other senior decision makers a single (free!) event to assess the whole ecosystem—if you're not in the room, you're not part of the conversation.  Given the competitive intensity in the data platform space, with Snowflake, Google and Databricks all having significant presence and actively supporting their wider partner ecosystem, Microsoft's absence feels like a missed opportunity.</p>
<p>Away from the big 4 and their respective ecosystems, some of the most intriguing innovation was happening in "Discovery Zone" where smaller niche vendors could be found. Based on conversations and demonstrations, here are three emerging vendors addressing real pain points with differentiated approaches:</p>
<h3 id="lynk-ai-semantic-layer-for-agentic-analytics"><strong>Lynk AI: Semantic Layer for Agentic Analytics</strong></h3>
<p>As discussed earlier, the semantic layer provides essential context for AI agents. <a href="https://www.getlynk.ai/">Lynk AI</a> is building a semantic layer platform specifically designed for AI agents, not as an afterthought to traditional BI. Their focus on creating a "semantic graph" that gives AI context about your data relationships addresses the fundamental trust problem in agentic analytics.</p>
<h3 id="motherduck-duckdb-for-collaboration-at-scale"><strong>MotherDuck: DuckDB for Collaboration at Scale</strong></h3>
<p><a href="https://duckdb.org/">DuckDB</a> has become the darling of the data engineering community for its speed and simplicity.  See my blog series on <a href="https://endjin.com/blog/duckdb-rise-of-in-process-analytics-understanding-data-singularity">DuckDB</a> and also on <a href="https://endjin.com/blog/introducing-ducklake-lakehouse-architecture-reimagined-modern-era">DuckLake</a> for more background about this exciting new analytics engine. <a href="https://motherduck.com/">MotherDuck</a> takes the open-source DuckDB engine and wraps it with the operational capabilities enterprises need: cloud-native scaling, team collaboration, and managed operations.  If you're exploring alternatives to traditional data warehouses for analytical workloads, this is worth evaluating.  The platform is able to leverage the cutting edge in-process analytics engine provided by DuckDB to run blazingly fast queries at a TCO significantly lower than its competitors.</p>
<h3 id="dlthub-python-native-data-loading"><strong>dltHub: Python-Native Data Loading</strong></h3>
<p><a href="https://dlthub.com/">dltHub</a> offers a Python library and platform for data loading that's built for the way modern data teams actually work. Instead of configuring low-code tools, you write Python. This resonates with the growing population of Python-first data engineers who find traditional ETL tools too rigid.  This focus on DevEx (developer experience) becomes increasingly important as organizations aim to accelerate time to value, avoid vendor lock-in and minimize technical debt.</p>
<hr>
<p><strong>The Pattern</strong>: These three vendors exemplify a broader pattern emerging across the ecosystem—they're not trying to replace your entire platform. They're solving specific, high-value problems that mega vendors either overlook or address as afterthoughts. The strategic question for data leaders: when does the innovation and fit of a specialized vendor outweigh the simplicity of staying entirely within one mega platform's ecosystem?</p>
<p>Based on our experience, the answer is usually "hybrid"—use a mega platform for core capabilities, then integrate specialized vendors where they add meaningful value. The key is ensuring your chosen mega platform has strong commitment to open standards (Delta Lake, Iceberg, Arrow) that enable this flexibility.</p>
<h2 id="beyond-technology-the-socio-technical-reality">Beyond Technology: The Socio-Technical Reality</h2>
<p>One of the most encouraging observations from this year's event: growing recognition that data transformation is a <a href="https://endjin.com/blog/data-is-a-socio-technical-endeavour">socio-technical endeavour</a>. Success requires alignment of people, processes, and technology.</p>
<h3 id="people-skills-are-the-bottleneck">People: Skills Are the Bottleneck</h3>
<p>The prominence of training and education providers reflects the reality that skills, not tools or platforms, constrain progress.</p>
<p>Organizations face a critical choice: hire expensively for scarce external talent, or invest in developing internal capability. Our client engagements consistently demonstrate that building your own data talent yields better long-term results. These people already understand your business, culture, and challenges.</p>
<p>The prominence of training providers and career-switchers at the event reflects the recognition that people capability is the real constraint, and focusing on this as a primary concern is an opportunity for organizations.</p>
<h3 id="processes-governance-as-enablement">Processes: Governance as Enablement</h3>
<p>The strong showing of governance vendors reflects painful lessons learned. The 25+ vendors in the Data Governance &amp; Quality category exist because enterprises have learned that data sprawl without governance creates expensive problems: compliance violations, incorrect decisions, duplicated effort, lost trust.</p>
<p>The most effective implementations position governance as enablement—guardrails that let people move faster safely, not gates that stop movement.</p>
<h3 id="technology-the-amplifier-not-the-solution">Technology: The Amplifier, Not the Solution</h3>
<p>With people and processes in place, technology becomes the amplifier rather than the solution. The most compelling vendor demonstrations weren't about technical capabilities; they were about how technology enables better collaboration, faster decision-making, and trusted outcomes.</p>
<p>Evaluate your transformation through all three dimensions simultaneously. If you're investing heavily in technology but not addressing skills development and governance processes, you're setting yourself up for expensive failures.</p>
<h2 id="regulatory-intelligence-governance-gets-strategic">Regulatory Intelligence: Governance Gets Strategic</h2>
<p>The collaborative panel featuring <a href="https://ico.org.uk/">ICO</a>, <a href="https://www.fca.org.uk/">FCA</a>, and <a href="https://www.ofcom.org.uk/">OFCOM</a>—three major UK regulators discussing their roles in an AI-enabled world—signals an important shift.</p>
<p>Rather than regulators reacting to problems after they occur, they're proactively positioning how to think about AI governance, data protection, and consumer safety. This matters because proactive regulatory alignment is becoming a competitive advantage. While competitors scramble with remediation, organizations that build with compliance from the start move faster and with greater confidence.</p>
<p>The regulators aren't trying to stop innovation—they're trying to ensure it happens responsibly. Organizations that proactively embrace regulatory frameworks will move with confidence while competitors struggle with remediation.</p>
<p>Regulatory compliance is increasingly a competitive differentiator. Organizations in highly regulated sectors should prioritize platforms and practices that help them meet these requirements rather than creating additional compliance headaches. Factor regulatory positioning into your platform selection criteria now.</p>
<h2 id="three-strategic-imperatives-from-big-data-ldn-2025">Three Strategic Imperatives from Big Data LDN 2025</h2>
<ol>
<li><p><strong>Invest in Foundations Before Racing to Agents</strong> - The organizations demonstrating real AI success had established semantic layers, governance frameworks, and data product practices first. Don't skip generations of maturity—the excitement around AI agents is justified, but the differentiator will be those who deploy them on solid foundations.</p>
</li>
<li><p><strong>Solve for People and Process, Not Just Technology</strong> - Technology platforms are maturing rapidly, and open standards (Delta Lake, Iceberg) reduce vendor lock-in risks. But people capability remains the constraint. Focus on building internal data literacy and establishing governance as enablement, not bureaucracy.</p>
</li>
<li><p><strong>Focus on Patterns and Practices, Not Specific Tools</strong> - Many organizations are in a position to skip generations of technology, jumping from legacy systems to modern cloud-native architectures. But with limited hands-on experience, technology selection feels daunting. By focusing on patterns (data as a product) and practices (DataOps), you maintain flexibility to transition between platforms as needs evolve.</p>
</li>
</ol>
<h2 id="final-thought">Final Thought</h2>
<p><a href="https://www.bigdataldn.com/">Big Data LDN 2025</a> demonstrated that the industry continues to evolve at pace. The most valuable discussions weren't about which platform has the fastest query performance or the most connectors. They were about how organizations successfully navigate the socio-technical complexity of data transformation, how they build trustworthy AI on solid foundations, and how they develop the organizational capabilities that technology alone can never provide.</p>
<p>For senior leaders making strategic decisions now: the opportunity is significant, but so is the risk of rushing ahead without proper foundations. The winners will be those who recognize that AI agents represent the next chapter, not the entire story, and that the story requires careful authoring across people, process, and technology.</p>
<p><strong>Ready to assess your organization's readiness for agentic intelligence?</strong> Consider where you are across the three imperatives above. The organizations thriving in 2025 started this foundational work in 2022-2023. Don't wait for the next Big Data LDN to realize you're behind.</p>
<p>Note: Big Data LDN records and publishes theatre sessions to YouTube in the weeks following the event. If you missed sessions you prioritized, they'll be available for thoughtful consumption soon. Also consider smaller community-driven events like <a href="https://datascotland.org/">DATA:Scotland</a>, <a href="https://datasciencefestival.com/">Data Science Festival</a>, and <a href="https://sqlbits.com/">SQLbits</a> (on the Saturday) which offer free attendance with a less overwhelming scale.</p>]]></content:encoded>
    </item>
    <item>
      <title>FabCon Vienna 2025: Day 3</title>
      <description>FabCon is a conference dedicated to everything Microsoft Fabric. Day 3's sessions included migration, Databricks, Spark optimisation, and more.</description>
      <link>https://endjin.com/blog/fabcon-vienna-2025-day-3</link>
      <guid isPermaLink="true">https://endjin.com/blog/fabcon-vienna-2025-day-3</guid>
      <pubDate>Wed, 08 Oct 2025 05:15:00 GMT</pubDate>
      <category>Microsoft Fabric</category>
      <category>FabCon</category>
      <category>Data</category>
      <category>Data Factory</category>
      <category>OneLake</category>
      <category>Copilot</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/10/fabcon-vienna-2025-day-3.png" />
      <dc:creator>Carmel Eve</dc:creator>
      <content:encoded><![CDATA[<p>After a busy start to FabCon Vienna, day 3 continued with a focus on practical sessions and technical updates. The sessions covered migration from Azure Synapse, platform integration, and performance improvements.</p>
<h2 id="accelerating-fabric-migration-new-assistant-tools-for-data-engineering-and-warehousing">Accelerating Fabric Migration: New Assistant Tools for Data Engineering and Warehousing</h2>
<p>(<a href="https://www.linkedin.com/in/jenny-jiang-8b57036/">Jenny Jiang</a> - Microsoft, <a href="https://www.linkedin.com/in/charleswebb22/">Charles Webb</a> - Microsoft)</p>
<p>The morning kicked off with a session on Synapse to Fabric migration. The new Migration Assistant looks useful. It can migrate Spark pools, notebooks, job definitions, and lake databases. But, it is worth keeping in mind that it doesn't touch pipelines, which is still going to be a bit of a headache for anyone with complex ETL flows!</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/10/fabric-migration.jpg" alt="Slide showing easy migration of assets." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/10/fabric-migration.jpg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/10/fabric-migration.jpg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/10/fabric-migration.jpg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/10/fabric-migration.jpg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<h2 id="powering-ai-foundry-agents-with-azure-ai-search">Powering AI Foundry Agents with Azure AI Search</h2>
<p>(<a href="https://www.linkedin.com/in/farzadsunavala/">Farzad Sunavala</a> - Microsoft)</p>
<p>This session was a deep dive into Azure AI Foundry and how it integrates with Azure AI Search for RAG (Retrieval Augmented Generation) scenarios.</p>
<ul>
<li>Azure AI Foundry is best used for more complex business processes</li>
<li>Azure AI Foundry Agent Service - connects the core pieces of Azure Foundry into a single runtime, combining:
<ul>
<li>Azure AI Foundry Models (with most people gravitating towards OpenAI models)</li>
<li>Tools like Azure AI Search, Azure Machine Learning, and Azure AI Services.</li>
<li>Content filters and enterprise security</li>
<li>Observability through tracing modules and evaluation frameworks.</li>
</ul>
</li>
</ul>
<p>The session focused heavily on Azure AI Search and RAG patterns.</p>
<p>RAG allows you to ground LLM responses in your own data rather than relying solely on pre-trained knowledge. When a user asks a question, the data store is searched, and the results are combined with the original question to generate contextually relevant answers.</p>
<p>RAG can be used to tackle harder questions from humans and agents, such as:</p>
<ul>
<li>Producing multi-stage queries</li>
<li>Achieving the aim of "natural language to SQL"</li>
</ul>
<p>They outlined the <strong>RAG framework pattern</strong> with three main components:</p>
<h3 id="data-pipeline">Data Pipeline</h3>
<p>This is probably the most critical step to get right - requiring clean, standardised data across multiple sources.</p>
<ul>
<li>Ingest - Multiple different data sources unified (can use OneLake shortcuts and mirroring).</li>
<li>Extract - Parse raw documents from tables, images, pdfs into a usable format.</li>
<li>Chunk - Split large documents into smaller segments to fit context windows.</li>
<li>Embed - Convert chunks into vector embeddings.</li>
<li>Index - Store embeddings, processed data and enriched content for efficient querying.</li>
</ul>
<h3 id="query-retrieval-pipeline">Query / Retrieval Pipeline</h3>
<p>The query/retrieval pipeline was broken down into several key steps:</p>
<ul>
<li><strong>Transform query</strong> – The LLM optimises the raw user input, making it more effective for search.</li>
<li><strong>Retrieve</strong> – The system searches for relevant information based on the optimised query. This stage includes:
<ul>
<li>Vector Search</li>
<li>Keyword Search - Which is sometimes required when vector search is not sufficient.</li>
<li>And, Agentic Retrieval.</li>
</ul>
</li>
<li><strong>Re-ranking</strong> – Results are reordered using semantic ranking to ensure the most relevant items appear first.</li>
<li><strong>Generate response</strong> – The LLM uses the retrieved information and prompt to generate a contextual answer.</li>
</ul>
<p>The true strength of this pattern lies in its advanced retrieval pipelines, which include two key stages:</p>
<ul>
<li><strong>Query pre-processing</strong>: Involves query planning to optimise how searches are executed.</li>
<li><strong>Retrieval</strong>: Utilises vector search, keyword search, and agentic retrieval methods.</li>
</ul>
<div class="aside"><p><strong>Agentic Retrieval</strong>: Enabled by a new API within Azure AI Search, agentic retrieval applies techniques such as:</p>
<ul>
<li>Query planning</li>
<li>Fan-out query execution</li>
<li>Results merging</li>
<li>And, within a single LLM call, it can:
<ul>
<li>Use conversation history for added context</li>
<li>Correct spelling errors contextually</li>
<li>Break down complex queries as needed</li>
<li>Paraphrase queries for clarity</li>
<li>Rewrite queries using acronyms - you can do this providing the LLM with a JSON containing acronym definitions</li>
</ul>
</li>
</ul>
</div>
<h3 id="agent-orchestration">Agent Orchestration</h3>
<p>The agent orchestration layer brings together all components needed for end-to-end RAG pipelines, enabling seamless integration for GenAI deployments. It supports:</p>
<ul>
<li><strong>AI Models</strong>: Including multimodal models, embedding models, and enrichment models.</li>
<li><strong>Developer Frameworks</strong>: Integrations with GitHub, Copilot Studio, Azure AI Foundry, and open-source tools.</li>
</ul>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/10/rag-platform-integrations.png" alt="Slide showing platform integrations." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/10/rag-platform-integrations.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/10/rag-platform-integrations.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/10/rag-platform-integrations.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/10/rag-platform-integrations.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<h3 id="storage-and-cost-optimisation-in-azure-ai-search">Storage and Cost Optimisation in Azure AI Search</h3>
<p>The session also covered techniques for optimising storage and reducing costs in Azure AI Search:</p>
<ul>
<li><strong>Scalar quantisation</strong>: Compresses vector data by lowering its resolution.</li>
<li><strong>Binary quantisation</strong>: Encodes each vector component as a single bit (1 or 0).</li>
<li><strong>Matryoska representative learning (MRL)</strong>: Applies multi-level compression to vector embeddings.</li>
<li><strong>Variable dimensions</strong>: Automatically reduces storage requirements for vector indexes.</li>
<li><strong>Narrow types</strong>: Uses smaller data types (e.g., float16, int16, int8) for vector fields to minimise memory and disk usage.</li>
</ul>
<h3 id="demo-highlights">Demo Highlights</h3>
<p><strong>Azure AI Foundry &amp; OneLake Demo</strong><br>
A brief demo showcased how "top-k" chunks are used in RAG scenarios. The web app combined AI Foundry and AI Search to retrieve the top 3 most relevant matches for a user query. You are able to see information that the prompt included, such filters, semantic captions, and semantic ranking, in order to generate the search query.</p>
<p><strong>Azure AI Foundry &amp; AI Search Demo</strong><br>
This demo walked through deploying a model (e.g., GPT-4.0) in Azure AI Foundry and creating a new agent. You can provide AI instructions, such as specifying access to Azure AI Search. The agent was connected to an Azure AI Search resource containing cardiology data from OneLake. The "hybrid and semantic search" configuration was recommended for domain-specific queries. When a user asked a question, the agent returned responses grounded in the AI Search data, and you could inspect the underlying search queries.</p>
<p><strong>Azure AI Search Portal (Agentic RAG Demo)</strong><br>
The final demo briefly showed how to create knowledge sources in the Azure AI Search portal. Within agentic retrieval, you can prioritise different knowledge sources for specific questions using the "Mode + Instructions" configuration.</p>
<h2 id="democratize-generating-business-insights-with-azure-databricks-through-ai-and-no-code">Democratize Generating Business Insights with Azure Databricks through AI and No-Code</h2>
<p>(<a href="https://www.linkedin.com/in/adamrwasserman/">Adam Wasserman</a> - Databricks, <a href="https://www.linkedin.com/in/isaacgritz/">Isaac Gritz</a> - Databricks)</p>
<p>Next up was a session on Azure Databricks. I imagined it would be a session about the integration between Azure Databricks and Fabric, but the session was more of a tour through Databricks' latest features.</p>
<p>There's a new <strong>business user portal</strong>, dashboards, and an AI assistant that lets you "chat with your data" - sound familiar? The "unity catalog business metrics" feature lets you centrally define things like revenue and save it in the catalog.</p>
<p><strong>Databricks Apps</strong> (GA) make it easy to build data apps over your data, which are "production ready" and built on serverless technology.</p>
<p><strong>Databricks One</strong> brings together all your data, apps, and dashboards for business users. This is great for data discovery, and it also supports integration with things like Teams.</p>
<p><strong>Lakeflows</strong> let you design no-code ETL (like dataflows). Alongside this GUI experience, you can also upload an image or table of your desired state, and AI will design it for you!</p>
<p>There is also support for designing no-code AI agents using "<strong>Agent Bricks</strong>".</p>
<p>Overall, it's clear that Databricks are moving in a direction similar to Fabric - with a focus on support for non-technical users and expanding out of the "ETL" space.</p>
<h2 id="scaling-and-protecting-data-engineering-in-fabric-best-practices-for-success">Scaling and Protecting Data Engineering in Fabric: Best Practices for Success</h2>
<p>(<a href="https://www.linkedin.com/in/thisissanthoshr/">Santhosh Kumar Ravindran</a> - Microsoft, <a href="https://www.linkedin.com/in/ashit-gosalia/">Ashit Gosalia</a> - Microsoft)</p>
<p>Back in Fabric-land, we attended a session on Spark optimisation at scale. This session ran through a load of "tips" for optimisation:</p>
<p>The new(ish) <strong>autoscale billing for Spark</strong> lets you scale compute independently of capacity. This is great as it means that you can start to use Fabric in a more serverless way - something we're big fans of here at endjin!</p>
<p>There are <strong>new controls</strong> to turn off starter pools (which might be bigger than you need), turn off job bursting, create custom pools at the capacity level, set max session lifetimes, and enable responsive scale-down (which means that executors are decommissioned when not active). Combined, these give you a lot of ways to limit excess usage.</p>
<p>They stressed the importance of enabling the <strong>native execution engine</strong> for massive performance improvements (at no extra cost). The engineering team has done loads of work on the native engine: faster write speeds, file size optimisation, auto file size optimisation, and alerts for when you fall back from the native engine. All these need to be toggled in pool or notebook settings.</p>
<p>There's a lot of additional features around private networking - for example, you can now <strong>block all outbound traffic</strong>. This means that by default you are unable to access anything external, unless it's on a specific allow list.</p>
<p>They touched on the new <strong>custom live pools</strong>, which should go live in Q1 next year.</p>
<p>There are big performance improvements coming for uploading JAR files and python packages.</p>
<p>And, as mentioned previously in the <a href="https://endjin.com/blog/fabcon-vienna-2025-day-1">previous blog on day 1</a>, you can massively increase <strong>notebook concurrency</strong>.</p>
<p><strong>Row and column-level security</strong> in Spark are coming soon.</p>
<p>And, finally, there's a <strong>new JDBC driver</strong> for integration with external orchestrators and systems.</p>
<h2 id="microsoft-purview-data-security-protections-in-the-ai-era">Microsoft Purview Data Security Protections in the AI Era</h2>
<p>(<a href="https://www.linkedin.com/in/shilparanganathan/">Shilpa Ranganathan</a> - Microsoft, <a href="https://www.linkedin.com/in/anton-fritz-70979918/">Anton Fritz</a> - Microsoft)</p>
<p>The final session covered data security challenges in the age of AI. With the rise of AI-generated attacks and the risk of inadvertent data exposure through tools like Copilot, organisations need robust security frameworks.</p>
<p>Microsoft Purview provides a unified approach to data security and governance, offering estate-wide protection for Fabric (and beyond!). You can use Fabric's built-in security features, or add Purview-specific capabilities with an additional license.</p>
<p>Key <strong>Purview security features for Fabric</strong> include:</p>
<ul>
<li><strong>Information protection</strong> (GA) - Using the same sensitivity labels as Office and SharePoint and Azure, with label-based access controls that follow data throughout Fabric and downstream to Office, PDF files and Power BI reports.</li>
<li><strong>Data loss prevention policies</strong> (GA) - Automatically scanning to detect sensitive data across semantic models, lakehouses, SQL, KQL, and mirrored databases. When violations are detected, you can take immediate remediation actions, including restricting access (Preview) to data owners only.</li>
<li><strong>Data risk assessments</strong> (Preview) - Discovering overshared data and identifying potential leakage risks. The assessment page shows metrics like unique user access patterns across Fabric workspaces.</li>
<li><strong>Insider risk management</strong> (GA) - Helps security teams identify potentially risky users and malicious activities, including monitoring Copilot prompts for Power BI.</li>
</ul>
<p>Here are some general ways to audit, monitor, and govern data using Microsoft Fabric and Purview:</p>
<h3 id="audit-monitoring">Audit &amp; Monitoring</h3>
<ul>
<li><strong>Fabric Admin Monitoring and Auditing</strong>: Fabric provides built-in admin monitoring tools to track activity, usage, and changes across your organisation’s data estate.</li>
<li><strong>Purview Audit</strong>: Microsoft Purview offers comprehensive audit capabilities, enabling you to monitor data access, usage, and policy compliance across multiple platforms.</li>
<li><strong>Integrated Auditing</strong>: Purview audit can be layered on top of Fabric’s native auditing, providing unified visibility and deeper insights into data operations and security events.</li>
</ul>
<h3 id="metadata-lineage">Metadata &amp; Lineage</h3>
<ul>
<li><strong>Fabric Lineage</strong>: Fabric’s lineage features allow you to trace data movement and transformations, helping you understand dependencies and the flow of data across workspaces.</li>
<li><strong>OneLake Catalog</strong>: The OneLake catalog centralises metadata management, making it easier to discover, classify, and govern data assets within Fabric.</li>
<li><strong>Purview Unified Catalog</strong>: Purview’s unified catalog extends metadata and lineage capabilities across your entire data estate, integrating with Fabric and other sources for holistic governance and compliance.</li>
</ul>
<p>The session emphasised that while Fabric has strong built-in security, Purview adds enterprise-grade governance across the entire data estate, which is increasingly critical as AI capabilities expand.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/10/fabric-and-purview-security.png" alt="Slide showing Fabric and Purview interoperability." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/10/fabric-and-purview-security.png 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/10/fabric-and-purview-security.png 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/10/fabric-and-purview-security.png 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/10/fabric-and-purview-security.png 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<h2 id="overall">Overall</h2>
<p>A packed few days, with a truly ridiculous amount of announcements and features - with many set to go live over the coming months. I'm looking forward to exploring a lot of these in more detail. And with that, I headed out to the alps to make the most of a long weekend in Austria!</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/10/me-hiking-austria.jpg" alt="A selfie of me in the mountains in Austria." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/10/me-hiking-austria.jpg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/10/me-hiking-austria.jpg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/10/me-hiking-austria.jpg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/10/me-hiking-austria.jpg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>Keep an eye out for coming articles and videos as we dig into the practicalities, share hands-on experiences, and see how these new features can be used in real-world projects!</p>
<div class="aside"><p>If you're interested in Microsoft Fabric, why not sign up to our new FREE <a href="https://fabricweekly.info/">Fabric Weekly Newsletter</a>? We also run <a href="https://azureweekly.info/">Azure Weekly</a> and <a href="https://powerbiweekly.info/">Power BI Weekly</a> Newsletters too!</p>
</div>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">FabCon Vienna 2025</h3>
        <span class="series-toc__count">3 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/fabcon-vienna-2025-day-1" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Day 1</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/fabcon-vienna-2025-day-2" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Day 2</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">3.</span>
                <span class="series-toc__part-title">Day 3</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>FabCon Vienna 2025: Day 2</title>
      <description>FabCon is a conference dedicated to everything Microsoft Fabric. Day 2 featured deep dives into OneLake, Maps in Fabric, and multi-agent AI systems.</description>
      <link>https://endjin.com/blog/fabcon-vienna-2025-day-2</link>
      <guid isPermaLink="true">https://endjin.com/blog/fabcon-vienna-2025-day-2</guid>
      <pubDate>Tue, 07 Oct 2025 06:15:00 GMT</pubDate>
      <category>Microsoft Fabric</category>
      <category>FabCon</category>
      <category>Data</category>
      <category>OneLake</category>
      <category>AI Foundry</category>
      <category>Copilot</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/10/fabcon-vienna-2025-day-2.png" />
      <dc:creator>Carmel Eve</dc:creator>
      <content:encoded><![CDATA[<p>Day 2 at FabCon, we shifted from the raft of announcements, into some more deep-dive technical sessions. We covered OneLake capabilities, the new Maps functionality, and some great AI integration demos.</p>
<p>Not only this... But I picked up my new favourite item of clothing!</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/10/fabric-socks-2.jpg" alt="Photo of me holding Fabric socks" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/10/fabric-socks-2.jpg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/10/fabric-socks-2.jpg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/10/fabric-socks-2.jpg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/10/fabric-socks-2.jpg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<h2 id="keynote-exploring-the-fabric-ecosystem-databases-security-and-ai-at-scale">Keynote - Exploring the Fabric Ecosystem: Databases, Security and AI at Scale</h2>
<p>(<a href="https://www.linkedin.com/in/shireeshthota/">Shireesh Thota</a> - Microsoft, <a href="https://www.linkedin.com/in/kimmanis/">Kim Manis</a> - Microsoft, <a href="https://www.linkedin.com/in/marcocasalaina/">Marco Casalaina</a> - Microsoft, <a href="https://www.linkedin.com/in/hawk-jessica/">Jessica Hawk</a> - Microsoft).</p>
<p>Day 2's Keynote was a far-reaching overview of the Fabric ecosystem as a whole.</p>
<p>They kicked off by showing how you can <strong>connect to Fabric items directly from VS Code</strong>, including deploying new SQL databases straight into your Fabric workspace.</p>
<p>They demonstrated "reverse-ETL" scenarios. These used Fabric to generate insights and then automate actions based on those insights. This is a powerful pattern for closing the loop between analytics and operations.</p>
<p>A significant chunk focused on AI, risk, and security. The <strong>new workspace-level security controls</strong> were interesting - Using these, you can enact policies that are either mandatory or monitored, with alerting for issues like bad data uploads.</p>
<p>There was new functionality highlighted for <strong>Fabric Data Agents</strong>:</p>
<ul>
<li><strong>Support for mirrored sources</strong> - meaning that mirrored data is now incorporated in queries and responses</li>
<li><strong>Git integration</strong> (like all Fabric objects, agents are now supported in Git.)</li>
<li>You can also now <strong>invoke agents from outside Fabric</strong> - for example, from Azure Foundry.</li>
</ul>
<p>They showed a real-time translation demo of a Teams call, which I can imagine being very useful! <strong>Translation in real-time</strong> would be another step in what (in my opinion) is one of the most powerful uses of AI as yet - in allowing communication across boundaries like never before.</p>
<p>Then came a GitHub integration demo. This used the "<strong>GitHub Spec Kit</strong>" to write specifications, then got an agent to create branches, implement changes, and raise PRs. Quite impressive, though I'd be interested to see how it's able to handle more complex scenarios and specifications! For the moment, I'd certainly at least want a human to be reviewing the PRs raised...</p>
<h2 id="bringing-data-into-onelake-a-deep-dive-into-shortcuts-and-mirroring">Bringing Data into OneLake: A Deep Dive into Shortcuts and Mirroring</h2>
<p>(<a href="https://blog.fabric.microsoft.com/en-gb/blog/author/Trevor%20Olson">Trevor Olson</a> - Microsoft, <a href="https://www.linkedin.com/in/marakiketema/">Maraki Ketema</a> - Microsoft)</p>
<p>This session provided a thorough exploration of OneLake's data virtualisation capabilities.</p>
<h3 id="shortcuts">Shortcuts</h3>
<p>Shortcuts in OneLake are virtual pointers which allow you to reference data stored in external sources (such as Azure Data Lake Storage, Amazon S3, or other lakehouses), without physically copying or moving the data.</p>
<p>This enables seamless access and unified analytics across multiple storage systems, reducing data duplication and simplifying data management. Shortcuts are native items within OneLake, making it easy for users and services to query and process external data as if it were local, while maintaining a single source of truth and improving collaboration across teams. The shortcutted data behaves identically to native data (just with a different icon!).</p>
<p>There have been multiple enhancements for shortcuts in OneLake:</p>
<ul>
<li><strong>File cache</strong> (GA) with configurable retention and API reset capabilities. This is enabled in the OneLake settings.</li>
<li><strong>Cross-cloud identity federation</strong> with S3 (public preview)</li>
<li><strong>Enhanced network security</strong>, where you can support trusted service access and don't need to define the gateways yourself</li>
<li><strong>Metadata transformation</strong> (GA)</li>
<li><strong>Data transformation</strong> (preview) - where you can apply transforms (parquet to delta, JSON to delta, and AI transforms) as part of the shortcut</li>
<li>You can now <strong>edit shortcuts</strong> (GA) rather than recreating them</li>
<li><strong>Git integration</strong> with variable mapping and workspace variables</li>
<li><strong>Key Vault support</strong> (GA)</li>
<li><strong>Azure Blob Storage shortcuts</strong> (GA) including anonymous auth</li>
<li>Improved <strong>Dataverse authentication</strong> options (Service Principal, Workspace ID)</li>
</ul>
<h3 id="mirroring">Mirroring</h3>
<p>If you want to see both your external data in OneLake and the metadata and catalog information that describes it, then you can use mirroring.</p>
<p>In mirroring scenarios, all metadata associated with the items is "mirrored" into the OneLake catalog. The data is also then mirrored, in one of two ways:</p>
<ul>
<li>For open data formats, shortcuts are created to the data.</li>
<li>For propriety data, a replica of the data is created within OneLake, and kept in-sync using change management. The compute used for this process is free, and the storage itself is also free up to a point.</li>
</ul>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/10/one-lake-connectors.jpg" alt="Slide showing OneLake connections" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/10/one-lake-connectors.jpg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/10/one-lake-connectors.jpg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/10/one-lake-connectors.jpg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/10/one-lake-connectors.jpg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p><strong>Open mirroring</strong> (preview) is a platform which allows you to extend mirroring to legacy systems and bespoke applications. Some partner companies have already built custom mirroring solutions on this platform.</p>
<p>The integration with <strong>Fabric Data Agents</strong> means you can pull mirrored data directly into agents and join tables from different sources. This is a powerful combination for cross-system analytics.</p>
<h2 id="geospatial-insights-for-everyone-with-maps-now-in-fabric">Geospatial Insights for Everyone with Maps now in Fabric</h2>
<p>(<a href="https://www.linkedin.com/in/johanneskebeck/">Johannes Kebeck</a> - Microsoft)</p>
<p>The next session we attended focused on the new Maps functionality in Fabric. It handles large datasets much better than Power BI maps and offers extensive customisation options. The integration with event streams enables real-time geospatial intelligence.</p>
<p>The technical implementation uses <strong>tilesets</strong> for performance. This creates vector tiles at different zoom levels and only showing data which will show up on the available pixels. All layers are stored as a single compressed file.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/10/vector-tiles.jpg" alt="Slide on why vector tiles are faster" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/10/vector-tiles.jpg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/10/vector-tiles.jpg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/10/vector-tiles.jpg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/10/vector-tiles.jpg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>The demo connected to an event stream and grouped geometry data into <a href="https://h3geo.org/">H3 tiles</a> (a geo-spatial indexing system developed by Uber) using KQL. They also showed a demo of using Fabric Maps to track faults across a power grid in real-time, and mentioned applications such as precision farming and queue management.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/10/h3-tiles.jpg" alt="Slide showing H3 tiles displayed on map." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/10/h3-tiles.jpg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/10/h3-tiles.jpg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/10/h3-tiles.jpg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/10/h3-tiles.jpg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>While it's not GA yet, once production-ready this could be a powerful tool for anyone needing to perform geospatial analysis, especially in real-time.</p>
<h2 id="operationalize-fabric-cicd-python-library-to-work-with-microsoft-fabric-and-azure-devops">Operationalize Fabric-CICD Python Library to Work with Microsoft Fabric and Azure DevOps</h2>
<p>(<a href="https://www.linkedin.com/in/kevin-chant/">Kevin Chant</a> - Macaw)</p>
<p>This session ended up being more of an Azure DevOps overview than the Git integration deep dive I was hoping for.</p>
<p>Here at endjin, coming from software development backgrounds, we are used to the option of working concurrently on software, at scale. Personally, I'm still a little unclear how you would achieve this given the current Git functionality in Fabric.</p>
<p>The advice seems to be to have a workspace per branch, or per developer, which they attach to a branch and make changes. But this solution doesn't seem like it would scale well across large development teams!</p>
<div class="aside"><p>Note for clarity - this isn't a criticism of the session, which was a good introduction to Azure Devops and the fabric-cicd python library - it just wasn't the session I had assumed it was (due to my misinterpretation of the session title)! The question above around branching policies and how to operationalisation of Fabric CI/CD in general did come up, and caught my attention due to it being one of my leading remaining questions around how teams should adopt and start to use Fabric in anger.</p>
</div>
<h2 id="integrating-microsoft-fabric-and-azure-ai-foundry-for-a-real-world-scenario">Integrating Microsoft Fabric and Azure AI Foundry for a Real-World Scenario</h2>
<p>(<a href="https://www.linkedin.com/in/m%C3%B2nica-calleja-3a856a185/">Monica Calleja</a> - Microsoft, <a href="https://www.linkedin.com/in/sara-lammini-rodriguez/">Sara Lammini Rodriguez</a> - Microsoft)</p>
<p>This was an excellent session on integration between Fabric and Azure Foundry. They walked through a real-world scenario tackling customer churn at a bank using a multi-agent system in Azure Foundry.</p>
<p>The <strong>multi-agent architecture</strong> was made up of:</p>
<ul>
<li><strong>Customer Info Retriever Agent</strong>: Used a Fabric Data Agent published as an endpoint.</li>
<li><strong>Customer Churn Prediction Agent</strong>: Connected to a machine learning model created in Fabric Data Science, trained on historical churn data and exposed via an endpoint.</li>
<li><strong>Loyalty Programmes Agent</strong>: Used Azure AI Search for RAG over PDF documents containing loyalty programme information, which was all vectorised and searchable.</li>
<li><strong>Orchestration Agent</strong>: Coordinated all three agents, determining which to call based on the question and parallelising calls to save tokens.</li>
</ul>
<p>The <strong>Agents Playground</strong> in AI Foundry provides a testing environment before deploying to production apps. You could see exactly which agents were called and how the orchestration worked.</p>
<p>The end result was a chat-bot interface where business users could ask: "Give me all info on Customer X. Are they likely to churn? If so, recommend a loyalty programme." The orchestration agent would call all relevant agents and synthesise a response.</p>
<p>This demo showcased the true potential of multi-agent systems. Specialised agents can work together to solve complex business problems, that might otherwise be challenging - for humans, or for AI!</p>
<h2 id="overall">Overall</h2>
<p>Day 2 provided much more hands-on technical content compared to the announcement-heavy Day 1. The OneLake deep dives, Maps functionality, and especially the multi-agent AI demo showed in-depth practical applications of the Azure stack, all brought together by Microsoft Fabric.</p>
<p>The day finished with a beautiful sunset over Vienna. Stay tuned for day 3!</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/10/vienna-sunset.jpeg" alt="Photo of sunset over Vienna" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/10/vienna-sunset.jpeg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/10/vienna-sunset.jpeg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/10/vienna-sunset.jpeg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/10/vienna-sunset.jpeg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<div class="aside"><p>If you're interested in Microsoft Fabric, why not sign up to our new FREE <a href="https://fabricweekly.info/">Fabric Weekly Newsletter</a>? We also run <a href="https://azureweekly.info/">Azure Weekly</a> and <a href="https://powerbiweekly.info/">Power BI Weekly</a> Newsletters too!</p>
</div>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">FabCon Vienna 2025</h3>
        <span class="series-toc__count">3 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/fabcon-vienna-2025-day-1" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Day 1</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">2.</span>
                <span class="series-toc__part-title">Day 2</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/fabcon-vienna-2025-day-3" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Day 3</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Rx.NET v6.1 New Feature: ResetExceptionDispatchState()</title>
      <description>&lt;p&gt;In this video, &lt;a href="https://endjin.com/who-we-are/our-people/ian-griffiths/"&gt;Ian Griffiths&lt;/a&gt; introduces the new &lt;code&gt;ResetExceptionDispatchState&lt;/code&gt; operator in &lt;a href="https://www.nuget.org/packages/System.Reactive/6.1.0"&gt;Rx.NET 6.1&lt;/a&gt; released in October 2025.&lt;/p&gt;
&lt;p&gt;He explains the peculiar behaviour of exception stack traces that led to the creation of this operator, following feedback from &lt;a href="https://github.com/adamjones2"&gt;Adam Jones&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The video delves into how exception state is managed in .NET and the specific issues that arise when exceptions are reused without being re-thrown. Ian demonstrates the problem with code examples and shows how the new operator resolves it.&lt;/p&gt;
&lt;p&gt;Full documentation is available at &lt;a href="https://introtorx.com/"&gt;Introduction to Rx.NET&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=_4BVPQit6EM&amp;amp;t=0s"&gt;00:00&lt;/a&gt; Introduction to Rx.NET's New Feature&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=_4BVPQit6EM&amp;amp;t=35s"&gt;00:35&lt;/a&gt; Background and Origin of the New Operator&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=_4BVPQit6EM&amp;amp;t=155s"&gt;02:35&lt;/a&gt; Understanding Exception State in .NET&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=_4BVPQit6EM&amp;amp;t=337s"&gt;05:37&lt;/a&gt; Demonstrating the Issue with Examples&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=_4BVPQit6EM&amp;amp;t=751s"&gt;12:31&lt;/a&gt; Introducing the &lt;code&gt;ResetExceptionDispatchState&lt;/code&gt; Operator&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=_4BVPQit6EM&amp;amp;t=918s"&gt;15:18&lt;/a&gt; Conclusion and Further Resources&lt;/li&gt;
&lt;/ul&gt;</description>
      <link>https://endjin.com/what-we-think/talks/rxdotnet-v6-1-new-feature-resetexceptiondispatchstate</link>
      <guid isPermaLink="true">https://endjin.com/what-we-think/talks/rxdotnet-v6-1-new-feature-resetexceptiondispatchstate</guid>
      <pubDate>Fri, 03 Oct 2025 16:33:00 GMT</pubDate>
      <category>Reactive Extensions</category>
      <category>dotnet</category>
      <category>Rx.NET</category>
      <category>NuGet</category>
      <category>Reactive Programming</category>
      <category>ReactiveX</category>
      <category>C#</category>
      <category>Open Source</category>
      <category>.NET Foundation</category>
      <category>Talk</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/rx-dotnet-dispatch-state.png" />
      <dc:creator>Ian Griffiths</dc:creator>
      <content:encoded><![CDATA[<p>In this video, <a href="https://endjin.com/who-we-are/our-people/ian-griffiths/">Ian Griffiths</a> introduces the new <code>ResetExceptionDispatchState</code> operator in <a href="https://www.nuget.org/packages/System.Reactive/6.1.0">Rx.NET 6.1</a> released in October 2025.</p>
<p>He explains the peculiar behaviour of exception stack traces that led to the creation of this operator, following feedback from <a href="https://github.com/adamjones2">Adam Jones</a>.</p>
<p>The video delves into how exception state is managed in .NET and the specific issues that arise when exceptions are reused without being re-thrown. Ian demonstrates the problem with code examples and shows how the new operator resolves it.</p>
<p>Full documentation is available at <a href="https://introtorx.com/">Introduction to Rx.NET</a>.</p>
<ul>
<li><a href="https://www.youtube.com/watch?v=_4BVPQit6EM&amp;t=0s">00:00</a> Introduction to Rx.NET's New Feature</li>
<li><a href="https://www.youtube.com/watch?v=_4BVPQit6EM&amp;t=35s">00:35</a> Background and Origin of the New Operator</li>
<li><a href="https://www.youtube.com/watch?v=_4BVPQit6EM&amp;t=155s">02:35</a> Understanding Exception State in .NET</li>
<li><a href="https://www.youtube.com/watch?v=_4BVPQit6EM&amp;t=337s">05:37</a> Demonstrating the Issue with Examples</li>
<li><a href="https://www.youtube.com/watch?v=_4BVPQit6EM&amp;t=751s">12:31</a> Introducing the <code>ResetExceptionDispatchState</code> Operator</li>
<li><a href="https://www.youtube.com/watch?v=_4BVPQit6EM&amp;t=918s">15:18</a> Conclusion and Further Resources</li>
</ul>
<p><a href="https://www.youtube.com/watch?v=_4BVPQit6EM"><img src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/rx-dotnet-dispatch-state.png"></a></p><p>I'm gonna talk about the new reset exception dispatch state operator that we've added to Rx.NET. So we released Rx version 6.1 in October 2025. And the reason that's a minor bump release is that we've added some new features, so there's <code>DisposeWith</code> and <code>TakeUntil(CancellationToken)</code>, and there are separate videos on both of them.</p>
<p>And today I'm gonna talk about the <code>ResetExceptionDispatchState</code> operator. Now if you've been following Rx's progress, you'll know we're also working on fixing some packaging issues, but that's not gonna come out until Rx v7.0.</p>
<p>What is the reset exception dispatch state operator about? So this originated from some feedback we had from Adam Jones. Adam initially reported a peculiar behavior, and then we discussed it and worked on some ways that we might be able to deal with this and eventually came up with the design for the <code>ResetExceptionDispatchState</code>, with his review and input. So, thank you very much to Adam!</p>
<p>So, the observed behavior that has led to this addition is that sometimes you can end up with some very odd looking stack traces in exceptions in Rx. So, if you use Rx in a way that an exception that originates from an observable source ends up turning into an actual thrown .NET exception. So, for example, if you <code>await</code> an <code>IObservable</code> and that <code>IObservable</code> produces an error, then sometimes you can end up with strange repetitions in your stack traces.</p>
<p>So, if you look at this stack trace on the screen here, you can see it's basically got the same information three times over. And actually where this was thrown from, the call stack just had that one time over. This does not reflect reality, and this was the behavior that led to the initial bug report.</p>
<p>Now this only happens under certain circumstances, so you'll only see this behavior if you have a single exception object that gets reused without being thrown between those uses. So without there being an actual C# <code>throw</code> operation or an equivalent in a different language. And not only does that have to happen, but Rx also has to convert that error from the normal Rx <code>OnError</code> mechanism into an actual rethrow. So, this happens when you <code>await</code> an observable that ends up calling <code>OnError</code> on its observer, for example.</p>
<p>And what's actually happening here is that the exception state is not being reset. So what do I mean by exception state? When an exception is thrown, the .NET runtime captures certain contextual information in order to report information about where the error actually came from. So this includes the stack trace, but it also includes certain other information that is known collectively as the crash bucket or the fault bucket. And if, for example, you're using Windows Error Reporting, this enables errors to be distinguished from one another.</p>
<p>So, the basic idea behind this exception state is to be able to distinguish between different causes of exceptions and application developers can sign their applications up to receive information about this if the user consents. And the basic idea here is, so if lots of users of your application are having the same problem, you can find out about that and prioritize fixing that problem over any others.</p>
<p>So the idea is that when an exception is thrown, this fault bucket information is captured and attached to the exception. And if that exception eventually gets thrown back out of the application and causes it to crash completely, that crash bucket information can be recorded by Windows. So that's the exception state that I'm talking about and it includes a stack trace as well.</p>
<p>And by design, Rx does not reset that exception state when you give it an exception, because there might be important information there, you might actually want to record that information. So actually the behavior we're seeing is not technically a bug, and actually you can get exactly the same weird multiple copies of the stack trace; it can happen with .NET without using Rx, this is actually a .NET runtime feature that occurs under certain circumstances, and the reason Rx is not resetting this exception state is that we want to flow exception state correctly in certain other circumstances and fundamentally, there are just some rules you have to follow if you don't wanna run into this behavior, but that was never previously obvious to developers using Rx.</p>
<p>So we've done a couple of things. We've updated the documentation on the <a href="https://introtorx.com/">https://introtorx.com</a> site to clarify the rules around this, to clarify that the original problem that Adam Jones reported to us is actually expected behavior because the coding question was not conforming to the rules. We've now made it clear what the rules are, so that people can know they should expect this, but also even if you did understand the constraints, it wasn't always that easy to do the right thing, and so we've added this <code>ResetExceptionDispatchState</code> operator to make it easy to do the right thing.</p>
<p>So let's take a look at it in action. So I'm actually gonna start with an example that shows why it is that Rx doesn't just reset this exception state. So I'm here, I'm using the <code>Observable.Create()</code> method to build a new <code>IObservable</code> that actually executes by running some code. So what this does is it's gonna try and open a file, and then it's gonna read each line from the file in turn and then deliver that to whoever has subscribed to this observable. So, this is like an imperative way of writing an observable source.</p>
<p>And then down here I subscribe to that source and I attempt to pull out the first non-empty line from the file. So, the thing is that the file I'm trying to open doesn't actually exist on my system, so this line here is gonna fail. So, what do you think will happen when we try to essentially subscribe to this observable source down here? Let's run it and find out.</p>
<p>So, you can see we get this <code>FileNotFoundException</code>, and you can see I've actually got a double stack trace. We've got one of these markers here that says, there's a stack trace here, then another stack trace, and this is typical of asynchronous exception handling in .NET. You get the original point at which the exception was thrown, and we can see that's happened on line 5 of my program and that is indeed the line that tried to open the file in the first place. So that was the original point at which the exception got thrown, but then it got thrown again at a different location because we are using <code>await</code>.</p>
<p>So, line 13 of my program, if I look at that, that's this one here. Here is where it got re-thrown. So, it originally got thrown here. Rx actually caught it and then when we did the <code>await</code> Rx re-threw it and we get the full stack trace information and as it happens, the crash bucket's also gonna identify this line here, all that information is present because Rx didn't reset it. And this is by design. We don't want to throw away this information.</p>
<p>So this example shows exactly why we are not discarding that information by default. But now I'm gonna show you a program where that causes a problem. Let me switch the debugger over to that one.</p>
<p>So here I'm using the <code>throw</code> method to build an observable source that will instantly just report this exception to anyone who subscribes to it. So inside the subscribe, which is gonna call your observer's <code>OnError</code>, straight back passing in this exception. But then I've written this loop here that <code>awaits</code> that observable each time round the loop and then prints out the exception.</p>
<p>Let's run this and see what happens. So, the first time round the loop, it catches the exception and reports the stack trace and we can see it's come from inside some of Reactive's implementation, but we can see it's being reported as originating as line 12 of my code. Let's take a look at that. Line 12 is this one here, it is the <code>await</code>. So far so good.</p>
<p>But now I'm gonna let this run again and I just need to clear that selection. Okay, so now we've hit the exception handler again, but this time we've got two copies of the stack and that does not accurately reflect what happened. The stack's not getting any deeper here. This is just a reporting error. The exception stack trace has grown, and if I go around again this time, it's gonna be three high. And if I let this run all the way to the end, 10 times round the loop, then at the very last time, we got 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 copies of the stack trace.</p>
<p>So this is the problem that was originally reported to us, but as I said, it's not technically a bug. For one thing, this is nothing to do with Rx. We could actually take Rx completely out of the equation and just construct the exception directly. And then if instead of awaiting my now doesn't exist observable source, if I do an <code>await</code> of <code>Task.FromException</code> of that exception, so now I'm just using .NET's <code>Task</code> class and telling it to wrap this exception as a faulted task.</p>
<p>If I run this again and let's just breakpoint the same place. So the first time around we get just a single line exception. But if I run it around again, now we get two copies of the stack trace and we run it again. Now we get three copies and so on.</p>
<p>So this problem isn't unique to Rx. It happens anytime you cause an exception to be re-thrown, either through <code>await</code> or through some other mechanism that's able to re-throw a previously captured exception.</p>
<p>If you re-throw an exception that was never technically thrown in the first place, and here you can see I've constructed my exception, but there's no <code>throw</code> operation anywhere in sight. If you do that, then each time you re-throw the exception, it just appends a bit more information to the stack trace. Because .NET's not really expecting you to do this.</p>
<p>The assumption when you re-throw is that it thinks you already have an exception that was thrown and you want to append a bit more information to the exception state saying, okay, this will have the original stack trace in it, but now you are doing an <code>await</code>, so I am going to add in the stack trace for where you are calling this from, appending it to what was already there and because nothing ever resets this exception, it just goes round and round again, and just keeps getting longer and longer.</p>
<p>And so if I put this back how it was, go back to the Rx version, that is why this is happening. We're constructing a single exception object and we're repeatedly causing Rx to re-throw it, but there's no original <code>throw</code> event. You might think, shouldn't the <code>throw</code> operator do that for us? Shouldn't this reset the thing? Shouldn't this do what <code>throw</code> does in C#? The reason it doesn't is that Rx says, you might have attached important information in here, and there's no good way to discover when you've passed in an exception that doesn't have exception state attached, there isn't an efficient way for us to discover that. So we just have to assume it might have that state, and therefore we don't touch it.</p>
<p>So how do we fix this? The first thing to understand is what the rules are. So the first thing we've actually done is we have modified the documentation. So this is a page from the Intro to Rx book, which is available online on the <a href="https://introtorx.com/">https://introtorx.com</a> website. It's also available in PDF form and the chapter about "Leaving Rx's World". So the chapter that talks about things like coming out of the world of <code>IObservable</code> and into the world of <code>Task</code>, which is what we are doing if we <code>await</code> an observable, we've added a section at the start here that talks about exception state and makes very clear what the rules are.</p>
<p>It says that if you are using a mechanism that takes exceptions delivered from an observable and that re-throws them, then you as the developer are responsible to ensure that either you don't reuse those exception objects. So each exception object is used just once or you do something to reset the exception dispatch state.</p>
<p>So this is the first thing we've done. We've documented these rules. Clearly these were never explicitly documented before, so it's not surprising people don't know that this isn't technically allowed. And then the other thing we've done is we've said, if you are in this situation where you do want to throw the same exception multiple times, we'll give you this new operator called <code>ResetExceptionDispatchState</code>.</p>
<p>So this operator passes all of the events that it receives straight through. So it doesn't change anything except for one thing: if the upstream source of the <code>throw</code>, in this case, if the upstream observable happens to produce an exception, if it calls <code>OnError</code>, then before this passes that on, it actually resets the <code>ExceptionDispatchState</code>, it does something equivalent to throwing the exception in order to reset the stack trace and all the other exception states.</p>
<p>So if we run this version, the first time around we see the stack trace pointing at line 13 of main, and also you'd get this inner one saying, okay, there's actually, the exception state was reset inside the <code>ResetExceptionDispatchState</code>. That's a side effect of us resetting the state inside that operator. So you'll always see this, but if we run it again, if I go this around the loop a second time, throwing the same exception, this time we just see one copy of the same stack trace. We don't see the thing growing. So if I let this run all 10 times, even the 10th time, you just get a single copy of the stack trace.</p>
<p>So to sum up, the basic problem here is that when you re-throw an exception in .NET without actually doing a real <code>throw</code>, you end up with these slightly strange results. The exception state accumulates, and that can cause odd looking stack traces. That's a basic .NET runtime behavior. We can't change this, and this is actually the expected behavior.</p>
<p>So what we've done is we've changed the documentation to make it clear that this is the situation. So people now have some way of knowing that they weren't supposed to do that in the first place. But we've also added this new operator <code>ResetExceptionDispatchState</code>, which you can add in to say, I don't want to keep the exception state. In fact, I need to not preserve the exception state. I need the exception state to be reset every single time an exception flows through this point in my subscription. And if you put that in, it will reset the exception each time the source produces it, avoiding the problem.</p>
<p>And if you go to <a href="https://introtorx.com/">https://introtorx.com</a>, you can see much more detail about exactly how this works.</p>
<p>My name's Ian Griffiths. Thanks very much for listening.</p>]]></content:encoded>
    </item>
    <item>
      <title>Rx.NET v6.1 New Feature: DisposeWith()</title>
      <description>&lt;p&gt;In this episode, &lt;a href="https://endjin.com/who-we-are/our-people/ian-griffiths/"&gt;Ian Griffiths&lt;/a&gt; introduces the new &lt;code&gt;DisposeWith&lt;/code&gt; method available in &lt;a href="https://www.nuget.org/packages/System.Reactive/6.1.0"&gt;Rx.NET version 6.1&lt;/a&gt;, released in October 2025.&lt;/p&gt;
&lt;p&gt;He discusses the new functionalities, including a new &lt;code&gt;TakeUntil()&lt;/code&gt; overload and the &lt;code&gt;ResetExceptionDispatchState&lt;/code&gt; operator, with additional videos to cover these features.&lt;/p&gt;
&lt;p&gt;Ian explains that &lt;code&gt;DisposeWith&lt;/code&gt; is a community contribution by &lt;a href="https://github.com/ChrisPulman"&gt;Chris Pullman&lt;/a&gt;, designed to facilitate a fluent coding style by simplifying the disposal of multiple subscriptions. He demonstrates the method in a console application, comparing it with the traditional CompositeDisposable approach, and highlights its ease of use for handling observable subscriptions.&lt;/p&gt;
&lt;p&gt;Full documentation is available at &lt;a href="https://introtorx.com/"&gt;Introduction to Rx.NET&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=6wQVb8iyLFU&amp;amp;t=0s"&gt;00:00&lt;/a&gt; Introduction to Dispose Operator in Rx.NET&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=6wQVb8iyLFU&amp;amp;t=43s"&gt;00:43&lt;/a&gt; Community Contribution and Background&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=6wQVb8iyLFU&amp;amp;t=86s"&gt;01:26&lt;/a&gt; Purpose and Design of &lt;code&gt;DisposeWith&lt;/code&gt; Method&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=6wQVb8iyLFU&amp;amp;t=107s"&gt;01:47&lt;/a&gt; Demo: &lt;code&gt;DisposeWith&lt;/code&gt; Method in Action&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=6wQVb8iyLFU&amp;amp;t=131s"&gt;02:11&lt;/a&gt; CompositeDisposable vs &lt;code&gt;DisposeWith&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=6wQVb8iyLFU&amp;amp;t=202s"&gt;03:22&lt;/a&gt; Fluent Style Development with &lt;code&gt;DisposeWith&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=6wQVb8iyLFU&amp;amp;t=272s"&gt;04:32&lt;/a&gt; Conclusion and Further Resources&lt;/li&gt;
&lt;/ul&gt;</description>
      <link>https://endjin.com/what-we-think/talks/rxdotnet-v6-1-new-feature-disposewith</link>
      <guid isPermaLink="true">https://endjin.com/what-we-think/talks/rxdotnet-v6-1-new-feature-disposewith</guid>
      <pubDate>Fri, 03 Oct 2025 16:32:00 GMT</pubDate>
      <category>Reactive Extensions</category>
      <category>dotnet</category>
      <category>Rx.NET</category>
      <category>NuGet</category>
      <category>Reactive Programming</category>
      <category>ReactiveX</category>
      <category>C#</category>
      <category>Open Source</category>
      <category>.NET Foundation</category>
      <category>Talk</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/rx-dotnet-dispose-with.png" />
      <dc:creator>Ian Griffiths</dc:creator>
      <content:encoded><![CDATA[<p>In this episode, <a href="https://endjin.com/who-we-are/our-people/ian-griffiths/">Ian Griffiths</a> introduces the new <code>DisposeWith</code> method available in <a href="https://www.nuget.org/packages/System.Reactive/6.1.0">Rx.NET version 6.1</a>, released in October 2025.</p>
<p>He discusses the new functionalities, including a new <code>TakeUntil()</code> overload and the <code>ResetExceptionDispatchState</code> operator, with additional videos to cover these features.</p>
<p>Ian explains that <code>DisposeWith</code> is a community contribution by <a href="https://github.com/ChrisPulman">Chris Pullman</a>, designed to facilitate a fluent coding style by simplifying the disposal of multiple subscriptions. He demonstrates the method in a console application, comparing it with the traditional CompositeDisposable approach, and highlights its ease of use for handling observable subscriptions.</p>
<p>Full documentation is available at <a href="https://introtorx.com/">Introduction to Rx.NET</a>.</p>
<ul>
<li><a href="https://www.youtube.com/watch?v=6wQVb8iyLFU&amp;t=0s">00:00</a> Introduction to Dispose Operator in Rx.NET</li>
<li><a href="https://www.youtube.com/watch?v=6wQVb8iyLFU&amp;t=43s">00:43</a> Community Contribution and Background</li>
<li><a href="https://www.youtube.com/watch?v=6wQVb8iyLFU&amp;t=86s">01:26</a> Purpose and Design of <code>DisposeWith</code> Method</li>
<li><a href="https://www.youtube.com/watch?v=6wQVb8iyLFU&amp;t=107s">01:47</a> Demo: <code>DisposeWith</code> Method in Action</li>
<li><a href="https://www.youtube.com/watch?v=6wQVb8iyLFU&amp;t=131s">02:11</a> CompositeDisposable vs <code>DisposeWith</code></li>
<li><a href="https://www.youtube.com/watch?v=6wQVb8iyLFU&amp;t=202s">03:22</a> Fluent Style Development with <code>DisposeWith</code></li>
<li><a href="https://www.youtube.com/watch?v=6wQVb8iyLFU&amp;t=272s">04:32</a> Conclusion and Further Resources</li>
</ul>
<p><a href="https://www.youtube.com/watch?v=6wQVb8iyLFU"><img src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/rx-dotnet-dispose-with.png"></a></p><p>I'm going to tell you about a new operator called <code>DisposeWith</code> that we've added to Rx.NET in version 6.1.</p>
<p>In October 2025, we released Rx v6.1, and it's a new minor release because it has new functionality. It has a new <code>DisposeWith()</code> method, which is the subject of this video. It has a <code>TakeUntil()</code> overload that takes a <code>CancellationToken</code>, and there's another video about that, and there'll also be another video about the new <code>ResetExceptionDispatchState</code> operator.</p>
<p>Now, if you've been following Rx for a while, you know we're also working on fixing some packaging options, but that's not going to happen till the next major release: v7.</p>
<p>So what's this <code>DisposeWith</code> method all about?</p>
<p>The first thing I should say is that this is a community contribution. Thanks very much to Chris Pullman for suggesting this and providing the code, being patient while we got it into place. Now Chris, as you may be aware, works on the ReactiveUI Framework. He's a very active contributor to the open source community, a long-term user of Rx with a deep understanding of the framework, and he felt this would be a very useful feature for the way that people tend to work with ReactiveUI. It's a fluent style of API, and people working with ReactiveUI find this way of working very useful. That's why we've decided to incorporate this suggestion into the Rx library.</p>
<p>The basic purpose of this addition is to make it simpler to discard multiple subscriptions with a single action. The way the API has been designed is to support what we call a fluent coding style. The best way to show how this works will be to show a quick demo.</p>
<p>Let's take a look at this new method in action. Right now I've got a very simple console application using Rx that has a couple of observable sources and it subscribes to both of them, lets those subscriptions run, and then when the user presses enter, it tears each of them down individually. This works. You can see they're both running there. When I hit enter, they both shut down, and that's fine. The thing is that I've had to call <code>dispose</code> separately on each one, and some people find that a bit onerous.</p>
<p>For a long time in Rx, we've had this thing called <code>CompositeDisposable</code>, which is essentially a collection of disposables, so we could just do this: add both of those disposables to this composite, and then we only have to call <code>dispose</code> on that composite object and the behavior should be exactly the same. Both my subscriptions are running. I hit enter. Both of them shut down, but this arguably hasn't really saved me anything because I actually got, if anything, slightly more code here.</p>
<p>We could do a little better. We could observe that this is actually a collection, and so maybe I could use the collection syntax to initialize this thing. Now I've said my composite disposable consists of a couple of these things, and now they're both running. I hit enter, they both get disposed.</p>
<p>That's an option, but I have had to rather reshape my code to work around this particular way of initializing it.</p>
<p>The idea with this new <code>DisposeWith</code> extension method is that it fits in a bit better with how you might want to set up your observable subscriptions in the first place. Instead of having to do this, we can just do <code>.DisposeWith(cd)</code>, so we just add this on the tail end of whatever our observable sequence for subscription ends up looking like. Now if I run this once more, I hit enter, my single call to the composite disposable disposes both of them.</p>
<p>It's a very simple change. It doesn't really add anything you couldn't do before, but it does enable this, what we call fluent style, development.</p>
<p>You'll notice that it brought in an additional namespace here. This means that if you're not a fan of fluent APIs—and not everyone is—then this won't appear in your namespace. Because this does become available on all disposables, it's only there if you choose to have it there. But if you like this style of development, then this is there for you.</p>
<p>In conclusion, this is a simple way to use the <code>CompositeDisposable</code> class to handle multiple unsubscriptions in a single action using a fluent style of API. If you want to use this, just download <code>System.Reactive</code> 6.1, add the using directive for the <code>System.Reactive.Disposables.Fluent</code> namespace, and then you can just call <code>DisposeWith</code> to attach each subscription to your existing composite disposable. We've documented this on <a href="https://introtorx.com/">https://introtorx.com</a> if you want more details.</p>
<p>Thanks for listening. My name's Ian Griffiths.</p>]]></content:encoded>
    </item>
    <item>
      <title>Rx.NET v6.1 New Feature: TakeUntil(CancellationToken)</title>
      <description>&lt;p&gt;In this video, &lt;a href="https://endjin.com/who-we-are/our-people/ian-griffiths/"&gt;Ian Griffiths&lt;/a&gt; introduces the new &lt;code&gt;TakeUntil(CancellationToken)&lt;/code&gt; operator in &lt;a href="https://www.nuget.org/packages/System.Reactive/6.1.0"&gt;Rx.NET 6.1&lt;/a&gt;, released in October 2025.&lt;/p&gt;
&lt;p&gt;He discusses the purpose and functionality of this operator, which allows users to stop an infinite source when a cancellation token is signalled.&lt;/p&gt;
&lt;p&gt;Ian acknowledges the contributions of community members &lt;a href="https://github.com/nilsauf"&gt;Nils Aufschläger&lt;/a&gt; and &lt;a href="https://github.com/danielcweber"&gt;Daniel Weber&lt;/a&gt; in shaping and developing this feature. Through a simple example using the Interval operator, Ian demonstrates how this new operator works and explains its benefits.&lt;/p&gt;
&lt;p&gt;Learn how to manage infinite sources effectively with the new &lt;code&gt;TakeUntil(CancellationToken)&lt;/code&gt; operator in Rx.NET 6.1.&lt;/p&gt;
&lt;p&gt;Full documentation is available at &lt;a href="https://introtorx.com/"&gt;Introduction to Rx.NET&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=-ivFY_Hv89Y&amp;amp;t=0s"&gt;00:00&lt;/a&gt; Introduction to Rx.NET 6.1 and New Features&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=-ivFY_Hv89Y&amp;amp;t=47s"&gt;00:47&lt;/a&gt; Community Contributions and Design Evolution&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=-ivFY_Hv89Y&amp;amp;t=87s"&gt;01:27&lt;/a&gt; Understanding the TakeUntil(CancellationToken) Operator&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=-ivFY_Hv89Y&amp;amp;t=188s"&gt;03:08&lt;/a&gt; Practical Example: Using TakeUntil with Interval&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=-ivFY_Hv89Y&amp;amp;t=360s"&gt;06:00&lt;/a&gt; Summary and Documentation&lt;/li&gt;
&lt;/ul&gt;</description>
      <link>https://endjin.com/what-we-think/talks/rxdotnet-v6-1-new-feature-takeuntil-cancellationtoken</link>
      <guid isPermaLink="true">https://endjin.com/what-we-think/talks/rxdotnet-v6-1-new-feature-takeuntil-cancellationtoken</guid>
      <pubDate>Fri, 03 Oct 2025 16:31:00 GMT</pubDate>
      <category>Reactive Extensions</category>
      <category>dotnet</category>
      <category>Rx.NET</category>
      <category>NuGet</category>
      <category>Reactive Programming</category>
      <category>ReactiveX</category>
      <category>C#</category>
      <category>Open Source</category>
      <category>.NET Foundation</category>
      <category>Talk</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/rx-dotnet-cancellation-token.png" />
      <dc:creator>Ian Griffiths</dc:creator>
      <content:encoded><![CDATA[<p>In this video, <a href="https://endjin.com/who-we-are/our-people/ian-griffiths/">Ian Griffiths</a> introduces the new <code>TakeUntil(CancellationToken)</code> operator in <a href="https://www.nuget.org/packages/System.Reactive/6.1.0">Rx.NET 6.1</a>, released in October 2025.</p>
<p>He discusses the purpose and functionality of this operator, which allows users to stop an infinite source when a cancellation token is signalled.</p>
<p>Ian acknowledges the contributions of community members <a href="https://github.com/nilsauf">Nils Aufschläger</a> and <a href="https://github.com/danielcweber">Daniel Weber</a> in shaping and developing this feature. Through a simple example using the Interval operator, Ian demonstrates how this new operator works and explains its benefits.</p>
<p>Learn how to manage infinite sources effectively with the new <code>TakeUntil(CancellationToken)</code> operator in Rx.NET 6.1.</p>
<p>Full documentation is available at <a href="https://introtorx.com/">Introduction to Rx.NET</a>.</p>
<ul>
<li><a href="https://www.youtube.com/watch?v=-ivFY_Hv89Y&amp;t=0s">00:00</a> Introduction to Rx.NET 6.1 and New Features</li>
<li><a href="https://www.youtube.com/watch?v=-ivFY_Hv89Y&amp;t=47s">00:47</a> Community Contributions and Design Evolution</li>
<li><a href="https://www.youtube.com/watch?v=-ivFY_Hv89Y&amp;t=87s">01:27</a> Understanding the TakeUntil(CancellationToken) Operator</li>
<li><a href="https://www.youtube.com/watch?v=-ivFY_Hv89Y&amp;t=188s">03:08</a> Practical Example: Using TakeUntil with Interval</li>
<li><a href="https://www.youtube.com/watch?v=-ivFY_Hv89Y&amp;t=360s">06:00</a> Summary and Documentation</li>
</ul>
<p><a href="https://www.youtube.com/watch?v=-ivFY_Hv89Y"><img src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/rx-dotnet-cancellation-token.png"></a></p><p>I am gonna tell you about a new operator introduced in Rx version 6.1, the <code>TakeUntil(CancellationToken)</code> operator. So in October 2025, we've released version 6.1, Rx.NET. It's a new minor version release because we've got new features in there, but no breaking changes. The new features are <code>DisposeWith</code>, which I'll talk about in a different video, the <code>TakeUntil(CancellationToken)</code> operator, which is the topic for this video, and also one called <code>ResetExceptionDispatchState</code>. Now, if you've been following the recent progress of Rx, you'll be aware that we're also looking to solve some longstanding packaging problems. That has not been done in the v6.1 release; that's coming in v7.0.</p>
<p>Okay, so let's take a look at this new operator <code>TakeUntil(CancellationToken)</code>. So just to start with, I should say thank you to the community people who have helped this come into being. So this was originally suggested as a slightly different form of feature, then changed shape.</p>
<p>So the original proposal came from someone whose name I suspect I will mispronounce, but I'll try anyway: Nils Aufschläger. So thank you for the initial proposal, and he also did the work to implement this, but the design changed during the discussion, and that is partly thanks to Daniel Weber, who chipped into the conversation and suggested a completely different approach from the one that Nils originally wanted to take.</p>
<p>What exactly is this? What's the purpose behind this new operator? Because we've had <code>TakeUntil</code> in various forms for a long time in Rx. Basically it says: take all the elements from this source until some criterion is met. And the new feature is that we are able to stop taking elements—to complete the source—when a cancellation token gets signaled.</p>
<p>And the purpose of this is to enable you to take an infinite source—so some source that would never complete on its own. So things like the interval timer are like this, but also the event sources that come from events in .NET. If you adapt a regular .NET event into an Rx source, it never completes by itself.</p>
<p>And that was actually the original motivation for this: was to enable those sorts of event sources to be completed. The original design was gonna be a specialized disposable implementation, but when we looked at that, it was kind of awkward. We didn't really have any other things that were both disposables and observables, and it wasn't clear how you would implement something that was both in a way that fit in well with the existing examples of those kinds of types in Rx.NET. So Daniel Weber's suggestion was that actually we could get the same effect—we could solve the same problem—by introducing this new overload of <code>TakeUntil</code> that takes a <code>CancellationToken</code>. And it's actually more flexible; it can work with anything that produces <code>CancellationToken</code>s. And we already have the <code>CancellationDisposable</code> in Rx.NET, which gives you a thing that you can dispose and which sets a <code>CancellationToken</code> when you do so. That was the way we moved forward.</p>
<p>Okay, so let's see this in action. I've got a very simple Rx example here. This is using the <code>Interval</code> operator that's been built into Rx forever, and this is just gonna raise events every second. So it's gonna produce the number 0, 1, 2, 3, and so on indefinitely. And the thing about interval is that it never stops. So when I run this, it's just gonna keep on producing numbers again and again and again, until either we unsubscribe or the process exits.</p>
<p>So we could just unsubscribe. So the <code>Subscribe</code> method does return an <code>IDisposable</code>, and we could hang onto that and then just call <code>Dispose()</code> on that, and that will certainly shut things down. However, what it won't do is enable the complete events to flow through. So when you unsubscribe, you never see the <code>OnCompleted</code>—or at least you might not see it. It's possible you'll see it during your call to <code>Subscribe()</code>, but there's no guarantee. So if you definitely want to see that, you really want the source itself to shut down, and that's the idea of this new operator—or this new overload of the <code>TakeUntil</code> operator.</p>
<p>So we're gonna need a <code>CancellationToken</code>. So I'm gonna use a <code>CancellationDisposable</code>, which is a type that has been built into Rx for a very long time, and that is able to provide us with a <code>CancellationToken</code>. So what we can do is say: let's take items from this until that <code>CancellationToken</code> is set. And what I can now do is, if I add a couple more things in here, if I dispose of that <code>CancellationDisposable</code> after the first time someone hits enter, that should shut down this observable. Let me just print out another thing here saying "Disposed," and then we're gonna keep running until we get a second key press. So if I run this now, then as before it starts producing numbers, and it will do so—or the underlying interval source will do that—indefinitely. But if I hit enter, that disposes the <code>CancellationDisposable</code>, and that causes the <code>CancellationToken</code> to enter the canceled state, and then our <code>TakeUntil</code> operator says, "Oh, okay, we're done then."</p>
<p>And we should now be able to see, if I add to my subscribe a second callback that says "Completed"—if I run this one more time. So that's producing numbers. I'm gonna hit enter to cause it to dispose that, and you can see right away the source completes properly. So this subscription has been shut down from the source end.</p>
<p>We haven't unsubscribed; we've caused the source itself to complete, and that is what <code>TakeUntil</code> enables us to do.</p>
<p>So in summary, this new <code>TakeUntil</code> operator overload enables us to cause otherwise infinite sources to complete when we want them to. So if you want to use this, you need to get hold of <code>System.Reactive</code> v6.1, and you need to then get a <code>CancellationToken</code> from somewhere. And you can use Rx's <code>CancellationDisposable</code> as one way of doing that, and then you just pass the token into the new <code>TakeUntil</code> overload.</p>
<p>If you want more details, we've documented this operator on the <a href="https://introtorx.com/">https://introtorx.com</a> site.</p>
<p>And meanwhile, I'm Ian Griffiths. Thanks for listening.</p>]]></content:encoded>
    </item>
    <item>
      <title>Rx.NET v6.1 Now Available</title>
      <description>Rx.NET 6.1 is now available, adding three new features: DisposeWith, a TakeUntil overload that takes a CancellationToken, and ResetExceptionDispatchState.</description>
      <link>https://endjin.com/blog/rx-dotnet-v6-1-released</link>
      <guid isPermaLink="true">https://endjin.com/blog/rx-dotnet-v6-1-released</guid>
      <pubDate>Fri, 03 Oct 2025 16:30:00 GMT</pubDate>
      <category>Rx</category>
      <category>Rx.NET</category>
      <category>Reactive Extensions</category>
      <category>Reactive</category>
      <category>System.Reactive</category>
      <category>C#</category>
      <category>CSharp</category>
      <category>dotnet</category>
      <category>Visual Studio</category>
      <category>Visual Studio Code</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/10/rx-dotnet-v6-1-released.png" />
      <dc:creator>Ian Griffiths</dc:creator>
      <content:encoded><![CDATA[<p>We've just released a new version of the <a href="https://github.com/dotnet/reactive">Reactive Extensions for .NET (Rx.NET)</a>. <a href="https://www.nuget.org/packages/System.Reactive/6.1.0"><code>System.Reactive</code> v6.1.0</a> is now available on NuGet.</p>
<h2 id="whats-new">What's new?</h2>
<p>We have three new features (hence the bump in the minor version number):</p>
<ul>
<li><code>DisposeWith</code> extension method for use with <code>CompositeDisposable</code> (see <a href="https://www.youtube.com/watch?v=6wQVb8iyLFU">video</a>)</li>
<li>New <code>TakeUntil</code> overload taking a <code>CancellationToken</code> (see <a href="https://www.youtube.com/watch?v=-ivFY_Hv89Y">video</a>)</li>
<li>New <code>ResetExceptionDispatchState</code> operator (see <a href="https://www.youtube.com/watch?v=_4BVPQit6EM">video</a>)</li>
</ul>
<h3 id="disposewith-extension-method"><code>DisposeWith</code> extension method</h3>
<p>Thanks to <a href="https://github.com/ChrisPulman">Chris Pulman</a> for implementing this.</p>
<p>This simplifies the use of <code>CompositeDisposable</code> when using a 'fluent' coding style. E.g.:</p>
<pre><code class="language-cs">CompositeDisposable d = new();

someObservable1.Subscribe(myObserver1)
    .DisposeWith(d);
someObservable2.Subscribe(myObserver2)
    .DisposeWith(d);
</code></pre>
<p>This video provides more detail:</p>
<p></p><div class="responsive-video pull-wide"><iframe src="https://www.youtube.com/embed/6wQVb8iyLFU" frameborder="0" allowfullscreen=""></iframe></div><p></p>
<h3 id="takeuntilcancellationtoken"><code>TakeUntil(CancellationToken)</code></h3>
<p>Thanks to <a href="https://github.com/nilsauf">Nils Aufschläger</a> for the initial suggestion, and for implementing the operator. Thanks to <a href="https://github.com/danielcweber">Daniel Weber</a> for proposing the design we ultimately chose.</p>
<p>The problem we wanted to solve was to be able to take an 'infinite' <code>IObservable&lt;T&gt;</code> (one that never calls <code>OnComplete</code>) and cause it to complete. Nils wanted to be able to trigger this completion by calling <code>Dispose</code> on some object. Daniel pointed out that if we added an overload to <code>TakeUntil</code> that accepts a <code>CancellationToken</code>, we can use Rx's existing <code>CancellationDisposable</code> to achieve this, while also enabling any other cancellation source to be used as well.</p>
<p>This video provides more detail:</p>
<p></p><div class="responsive-video pull-wide"><iframe src="https://www.youtube.com/embed/-ivFY_Hv89Y" frameborder="0" allowfullscreen=""></iframe></div><p></p>
<h3 id="resetexceptiondispatchinfo"><code>ResetExceptionDispatchInfo</code></h3>
<p>Thanks to <a href="https://github.com/adamjones2">Adam Jones</a> for the initial issue report, and for reviewing our work on this.</p>
<p>Adam reported a peculiar behaviour in which Rx was causing the <code>StackTrace</code> reported by an exception to grow longer each time it rethrew that exception. This would happen if the following were both true:</p>
<ul>
<li>something delivers the same exception to <code>OnError</code> more than once (e.g., because of multiple subscriptions to the same <code>Observable.Throw</code>)</li>
<li>you perform multiple <code>await</code>s that cause that same exception to be thrown</li>
</ul>
<p>Here's a contrived example that illustrates the issue.</p>
<pre><code class="language-cs">IObservable&lt;int&gt; ox = Observable.Throw&lt;int&gt;(new Exception("Bang!"));

try
{
    await ox;
}
catch (Exception x)
{
    // Exception will look normal here.
    Console.WriteLine(x);
}

try
{
    await ox;
}
catch (Exception x)
{
    // Exception will have duplicated stack trace here.
    Console.WriteLine(x);
}
</code></pre>
<p>Despite how it looks, this is not actually a bug. .NET itself will do exactly the same thing if you use <code>await Task.FromException(ex)</code> twice on the same exception object. The basic issue here is that there are some rules around when it is acceptable to rethrow an exception.</p>
<p>If we were to modify either <code>Throw</code> or Rx's <code>await</code> integration to stop this from happening, it would break some other important scenarios in which currently we flow exception origin information correctly. That means that the code shown above will always exhibit this behaviour and we can't change that without causing new problems.</p>
<p>Instead, in this release of Rx, we've done two things to address this issue:</p>
<ul>
<li>The documentation at <a href="https://introtorx.com/">IntroToRx.com</a> now alerts readers to this problem and describes how to avoid it in the various places where it is a relevant factor</li>
<li>We've added a new <code>ResetExceptionDispatchState</code> operator that enables you to get the behaviour you might have expected.</li>
</ul>
<p>We can avoid this stack trace repetition by using Rx 6.1's new <code>ResetExceptionDispatchState</code> operator. All it takes is a change to the first statement:</p>
<pre><code class="language-cs">IObservable&lt;int&gt; ox = Observable
    .Throw&lt;int&gt;(new Exception("Bang!"))
    .ResetExceptionDispatchState();
</code></pre>
<p>This tells Rx that the exception won't be populated with state such as a correct stack trace or fault bucket information at the point where it emerges from <code>Throw</code>, and so we need Rx to reset that information. (In effect, this operator performs a <code>throw</code> at the instant the exception emerges from the source.)</p>
<p>Existing scenarios that were relying on Rx's behaviour of preserving exception dispatch state info will continue to work because we have not changed the core behaviour. But examples that require that state to be reset on each call to <code>OnError</code> now have a straightforward way to achieve that.</p>
<p>This video provides more detail:</p>
<p></p><div class="responsive-video pull-wide"><iframe src="https://www.youtube.com/embed/_4BVPQit6EM" frameborder="0" allowfullscreen=""></iframe></div><p></p>
<h2 id="whats-next">What's next?</h2>
<p>Now that Rx 6.1 is out, we are turning our attention to the next release. It will need to be a new major version (Rx v7.0) because it will make these breaking changes:</p>
<ul>
<li>We will remove <code>net6.0</code> support</li>
<li>UI-framework-specific functionality will no longer be available through <code>System.Reactive</code>'s compile-time public API (but will remain in runtime assemblies for binary backwards compatibility); this functionality will move into new NuGet packages</li>
</ul>
<p>These are expressed as negatives because major version bumps are always about breaking changes. So what's the upside?</p>
<p>When .NET 10 ships, we will of course be supporting that. But if we don't manage to get Rx v7.0 out of the door shortly after .NET 10 ships, we'll just add .NET 10 tests to the test suite for Rx 6.1. So it's not yet clear whether upgrading to v7.0 will be required for us to offer .NET 10 support. (That said, Rx 6.0 and 6.1 will in fact work just fine on .NET 10.0. It's just a question of whether we officially support that. As far as I know, Rx 4.4 also works on .NET 9 today, but that's not a combination we support.)</p>
<p>The reason for the UI framework packaging changes in Rx 7.0 is that it will solve a problem that happens today if you build .NET apps with AoT or self-contained deployment that target Windows. With Rx 6.0 and 6.1, if your application has a Windows-specific TFM targetting Windows 10.0.19041 or later (e.g. <code>net6.0-windows10.0.19041</code> or <code>net9.0-windows10.0.22621</code>), the <code>System.Reactive</code> library imposes dependencies on WPF and Windows Forms. So even if you're not using either of those frameworks, an AoT deployment or a self-contained deployment will include a copy of both of those frameworks. This makes the deployable code tens of megabytes larger than it needs to be.</p>
<p>Unsurprisingly, this has proven unpopular. <a href="https://avaloniaui.net/">The AvaloniaUI project</a> abandoned Rx.NET completely because of it. So we will be fixing it in Rx 7.0. We have gone to extreme lengths to minimize the impact on existing code, but there will be some breaking changes in some situations, which is why we will be bumping the major version number.</p>
<h2 id="please-try-it-out">Please try it out</h2>
<p>This new 6.1 release of <a href="https://www.nuget.org/packages/System.Reactive"><code>System.Reactive</code></a> is available on NuGet today. If you're using Rx in your application, please try upgrading. If you have any problems, please file issues at <a href="https://github.com/dotnet/reactive/issues">https://github.com/dotnet/reactive/issues</a>. Meanwhile, we hope you enjoy this new version of the Reactive Extensions for .NET.</p>
<h2 id="more-rx-content">More Rx content</h2>
<p>As well as the two series from Carmel Eve's <a href="https://endjin.com/blog/understanding-rx-making-interfaces-subscribing-and-other-subjects-click">Rx Operators Deep Dive</a> and Richard Kerslake's <a href="https://endjin.com/blog/event-stream-manipulation-using-rx-part-1">Event stream manipulation for Rx with semantic logging</a>, you can find further information here:</p>
<ul>
<li><a href="https://introtorx.com/">Intro to Rx.NET 3rd Edition (2025)</a></li>
<li><a href="https://www.youtube.com/watch?v=dio_BKsS9hY&amp;list=PLJt9xcgQpM60Fz20FIXBvj6ku4a7WOLGb">Rx playlist</a> (on the <a href="https://www.youtube.com/endjin">endjin YouTube channel</a>)</li>
<li><a href="https://www.youtube.com/watch?v=dio_BKsS9hY&amp;list=PLJt9xcgQpM62UBIgAkHjAhzITWMGeXbGY">Rx 101 Workshop</a></li>
<li><a href="https://endjin.com/what-we-think/talks/reactive-extensions-for-dotnet">Rx talk</a> for the dotnetsheff user group</li>
<li><a href="https://reaqtive.net/">https://reaqtive.net/</a> — a persistent, reliable, distributed stream processing system based on Rx</li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title>Batch Processing Triggered Pipeline Runs in Azure Synapse</title>
      <description>Bursty event triggers in Azure Synapse can fire the same pipeline many times in quick succession. A batched-trigger orchestrator collapses them into a single run.</description>
      <link>https://endjin.com/blog/batch-triggered-pipeline-runs-azure-synapse</link>
      <guid isPermaLink="true">https://endjin.com/blog/batch-triggered-pipeline-runs-azure-synapse</guid>
      <pubDate>Thu, 02 Oct 2025 06:32:00 GMT</pubDate>
      <category>Data</category>
      <category>Analytics</category>
      <category>Synapse Pipelines</category>
      <category>Azure Data Factory</category>
      <category>Microsoft Fabric</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/09/batch-triggered-pipeline-runs-azure-synapse.png" />
      <dc:creator>James Broome</dc:creator>
      <content:encoded><![CDATA[<h2 id="overcoming-event-driven-limitations-batching-triggered-pipeline-runs-in-azure-synapse">Overcoming Event-Driven Limitations: Batching Triggered Pipeline Runs in Azure Synapse</h2>
<p>Azure Synapse Pipelines and Azure Data Factory Pipelines provide a flexible way to orchestrate data movement and transformation. Event-driven triggers are a useful feature, allowing pipelines to start automatically as soon as data lands in a storage account. This "just-in-time" processing is often ideal for responsive data ingestion and near-real-time analytics - something that's heavily pushed via the various <a href="https://learn.microsoft.com/en-us/azure/synapse-analytics/synapse-link/sql-synapse-link-overview">"Synapse Link for..." integrations</a>.</p>
<p>However, there are scenarios where a purely event-driven approach isn't quite the optimal fit. What if you have a high volume of small, frequent events, and you'd prefer to process them in larger, more efficient batches? Or perhaps you need to ensure that a pipeline, once triggered, doesn't immediately re-trigger if subsequent events arrive within a very short window, leading to an unwanted cascade of runs. This is where the limitations of the out-the-box event-based triggers can become apparent – they don't natively support batching.</p>
<p>Frequent, bursty pipeline triggers can cause numerous problems. When multiple external events (file arrivals, API calls, whatever your trigger source) try to kick off the same pipeline in short succession, you can end up with a queue of pipeline runs all doing the same thing, maybe with slight variations of the data being processed. Depending on the pipeline's complexity and the resources it consumes, this can lead to:</p>
<ul>
<li><strong>Long Delays:</strong> If 5 updates happened within a short period, and your pipeline takes 10 mins to run, you'll be waiting a long time to see the latest outputs</li>
<li><strong>Race Conditions:</strong> If your pipeline isn't designed to be idempotent, concurrent runs can lead to data inconsistencies</li>
<li><strong>Resource Contention:</strong> Multiple runs fighting for the same compute, storage, or API limits</li>
<li><strong>Performance Degradation:</strong> Everything slows down because the system is overloaded</li>
<li><strong>Unnecessary Costs:</strong> Paying for multiple parallel runs when a single, batched run would suffice</li>
</ul>
<p>In this post, I'm going to describe a reusable pattern that solves the problems described above, using out-the-box pipeline activities to achieve "batched triggered" pipeline runs in Azure Synapse Pipelines (and can equally be applied in Azure Data Factory). In a subsequent post, I'll describe how the same pattern can be adapted for Microsoft Fabric Data Pipelines.</p>
<h2 id="the-batched-trigger-orchestrator-pattern">The Batched Trigger Orchestrator Pattern</h2>
<p>The core idea behind this pattern is to introduce an "orchestrator" pipeline that is called from a parent "workload" pipeline. Upon the orchestrator being triggered, it checks for any other pending, queued runs of the same parent workload pipeline. If it finds them, it effectively signals to the parent pipeline to defer its own execution, passing on to the next triggered pipeline run in the queue. By specifying a configurable delay window, the net effect is that we can control how frequently a "batch" of triggered workload pipeline runs that have accumulated are actually executed.</p>
<p>For the approach to work, the parent workload pipeline needs to be set to only run once at a time using the pipeline concurrency settings. This forces the queue of trigger runs to build up, so that the orchestrator can decide which runs to execute and which runs to ignore. The parent workload pipeline receives the response from the orchestrator telling it whether to proceed or exit (quickly), and reacts accordingly.</p>
<p>Before we take a look at the pipeline definition, let's start with some scenarios.</p>
<h2 id="scenario-1-single-triggered-pipeline-no-queued-runs">Scenario 1: Single triggered pipeline (No Queued Runs)</h2>
<p>In the simplest case, where a single event triggers the pipeline, and no other events arrive within the batching window, then the pipeline executes as expected:</p>
<pre class="mermaid">sequenceDiagram
    participant D1 as Data Source
    participant E1 as Storage Event Trigger
    participant P1 as Workload Pipeline
    participant O1 as Batched Trigger Orchestrator Pipeline
    participant SynapseAPI as Synapse API
    participant P1_Work as Pipeline activities

    D1-&gt;&gt;E1: New event occurs (e.g., file arrival)
    E1-&gt;&gt;P1: Workload Pipeline is triggered
    P1-&gt;&gt;O1: Execute orchestration pipeline (20 second delay)
    O1-&gt;&gt;SynapseAPI: Query Synapse API for queued Workload Pipeline runs since trigger time 
    SynapseAPI--&gt;&gt;O1: Returns empty list
    O1-&gt;&gt;O1: Delay for 20 seconds
    Note over O1: No other Workload Pipeline runs initiated during 20 seconds
    O1-&gt;&gt;SynapseAPI: After 20s, query Synapse API for queued Workload Pipeline runs between trigger time and +20 seconds 
    SynapseAPI--&gt;&gt;O1: Returns empty list
    O1--&gt;&gt;P1: Signals result: Continue Execution
    P1-&gt;&gt;P1: Checks result
    P1-&gt;&gt;P1_Work: Execute workload
    P1_Work--&gt;&gt;P1: Work completes
    P1--&gt;&gt;P1: Workload Pipeline completes
</pre>
<h2 id="scenario-2-multiple-triggered-events-effective-batching">Scenario 2: Multiple triggered events (Effective Batching)</h2>
<p>In this scenario, several events arrive in quick succession, demonstrating how the orchestrator allows only one instance (the most recent) of the workload pipeline runs to proceed, effectively batching the work.</p>
<pre class="mermaid">sequenceDiagram
    participant D1 as Data Source
    participant E1 as Storage Event Trigger
    participant P1 as Workload Pipeline (Run 1)
    participant O1 as Batched Trigger Orchestrator Pipeline (Run 1)
    participant P2 as Workload Pipeline (Run 2)
    participant O2 as Batched Trigger Orchestrator Pipeline (Run 2)
    participant P3 as Workload Pipeline (Run 3)
    participant O3 as Batched Trigger Orchestrator Pipeline (Run 3)
    participant SynapseAPI as Synapse API
    participant P1_Work as Pipeline activities

    D1-&gt;&gt;E1: New event occurs (e.g., file arrival)
    E1-&gt;&gt;P1: Workload Pipeline is triggered
    P1-&gt;&gt;O1: Execute orchestration pipeline (20 second delay)
    O1-&gt;&gt;SynapseAPI: Query Synapse API for queued Workload Pipeline runs
    SynapseAPI--&gt;&gt;O1: Returns empty list
    O1-&gt;&gt;O1: Delay for 20 seconds

    D1-&gt;&gt;E1: New event occurs (+5 seconds) (e.g., file arrival)
    E1-&gt;&gt;P2: Workload Pipeline is queued

    D1-&gt;&gt;E1: New event occurs (+10 seconds) (e.g., file arrival)
    E1-&gt;&gt;P3: Workload Pipeline is queued
   

    O1-&gt;&gt;SynapseAPI: After 20s, query Synapse API for queued Workload Pipeline runs between trigger time and +20 seconds
    SynapseAPI--&gt;&gt;O1: Returns list containing Run 2 and 3
    O1--&gt;&gt;P1: Signals result: Cancel Execution
    P1-&gt;&gt;P1: Checks result
    P1--&gt;&gt;P1: Workload Pipeline exits

    P2-&gt;&gt;O2: Execute orchestration pipeline (20 second delay)
    O2-&gt;&gt;SynapseAPI: Query Synapse API for queued Workload Pipeline runs between trigger time and +20 seconds
    SynapseAPI--&gt;&gt;O2: Returns list containing Run 3
    O2-&gt;&gt;O2: Signals result: Cancel Execution
    P2-&gt;&gt;P2: Checks result
    P2--&gt;&gt;P2: Workload Pipeline exits

    P3-&gt;&gt;O3: Execute orchestration pipeline (20 second delay)
    O3-&gt;&gt;SynapseAPI: Query Synapse API for queued Workload Pipeline runs between trigger time and +20 seconds
    SynapseAPI--&gt;&gt;O3: Returns empty list
    O3-&gt;&gt;O3: Signals result: Continue Execution
    P3-&gt;&gt;P3: Checks result
    P3-&gt;&gt;P1_Work: Execute workload
    P1_Work--&gt;&gt;P3: Work completes
    P3--&gt;&gt;P3: Workload Pipeline completes
</pre>
<h2 id="scenario-3-events-spread-out-multiple-batches">Scenario 3: Events spread out (Multiple Batches)</h2>
<p>This scenario shows what happens if multiple triggered events arrive with sufficient gaps, leading to multiple successful runs of the workload pipeline.</p>
<pre class="mermaid">sequenceDiagram
    participant D1 as Data Source
    participant E1 as Event Trigger
    participant P1 as Workload Pipeline (Run 1)
    participant O1 as Batched Trigger Orchestrator Pipeline (Run 1)
    participant P2 as Workload Pipeline (Run 2)
    participant O2 as Batched Trigger Orchestrator Pipeline (Run 2)
    participant SynapseAPI as Synapse API
    participant P_Work as Pipeline activities

    D1-&gt;&gt;E1: New event occurs (e.g., file arrival)
    E1-&gt;&gt;P1: Workload Pipeline is triggered
    P1-&gt;&gt;O1: Execute orchestration pipeline (20 second delay)

    O1-&gt;&gt;SynapseAPI: Query Synapse API for queued Workload Pipeline runs 
    SynapseAPI--&gt;&gt;O1: Returns empty list
    O1-&gt;&gt;O1: Delay for 20 seconds
    O1-&gt;&gt;SynapseAPI: After 20s, query Synapse API for queued Workload Pipeline runs between trigger time and +20 seconds
    SynapseAPI--&gt;&gt;O1: Returns empty list
    O1--&gt;&gt;P1: Signals result: Continue Execution
    P1-&gt;&gt;P1: Checks result
    P1-&gt;&gt;P_Work: Execute workload
    P_Work--&gt;&gt;P1: Work completes
    P1--&gt;&gt;P1: Workload Pipeline completes

    D1-&gt;&gt;E1: New event occurs (+30 seconds) (e.g., file arrival)
    E1-&gt;&gt;P2: Workload Pipeline is triggered
    P2-&gt;&gt;O2: Execute orchestration pipeline (20 second delay)

    O2-&gt;&gt;SynapseAPI: Query Synapse API for queued Workload Pipeline runs 
    SynapseAPI--&gt;&gt;O2: Returns empty list
    O2-&gt;&gt;O2: Delay for 20 seconds
    O2-&gt;&gt;SynapseAPI: After 20s, query Synapse API for queued Workload Pipeline runs between trigger time and +20 seconds
    SynapseAPI--&gt;&gt;O2: Returns empty list
    O2--&gt;&gt;P2: Signals result: Continue Execution
    P2-&gt;&gt;P2: Checks result
    P2-&gt;&gt;P_Work: Execute workload
    P_Work--&gt;&gt;P2: Work completes
    P2--&gt;&gt;P2: Workload Pipeline completes
</pre>
<h2 id="pipeline-definition-batched-orchestrator">Pipeline Definition: Batched Orchestrator</h2>
<p>Here's the JSON definition for the "Batched Trigger Orchestrator" pipeline that can be used by any workload pipeline that needs to implement this pattern. This pipeline is entirely reusable and generic:</p>
<pre><code class="language-json">{
    "name": "Batched Trigger Orchestrator",
    "properties": {
        "activities": [
            {
                "name": "Initial pipeline queue check",
                "type": "WebActivity",
                "dependsOn": [],
                "policy": {
                    "timeout": "0.12:00:00",
                    "retry": 0,
                    "retryIntervalInSeconds": 30,
                    "secureOutput": false,
                    "secureInput": false
                },
                "userProperties": [],
                "typeProperties": {
                    "method": "POST",
                    "url": {
                        "value": "https://@{pipeline().DataFactory}.dev.azuresynapse.net//queryPipelineRuns?api-version=2020-12-01",
                        "type": "Expression"
                    },
                    "connectVia": {
                        "referenceName": "AutoResolveIntegrationRuntime",
                        "type": "IntegrationRuntimeReference"
                    },
                    "body": {
                        "value": "{\n  \"lastUpdatedAfter\": \"@{pipeline().parameters.CallingPipelineTriggerTime}\",\n  \"lastUpdatedBefore\": \"@{utcNow()}\",\n  \"filters\": [\n    {\n      \"operand\": \"PipelineName\",\n      \"operator\": \"Equals\",\n      \"values\": [\n        \"@{pipeline()?.TriggeredByPipelineName}\"\n      ]\n    },\n    {\n      \"operand\": \"Status\",\n      \"operator\": \"Equals\",\n      \"values\": [\n        \"Queued\"\n      ]\n    }\n  ]\n}\n",
                        "type": "Expression"
                    },
                    "authentication": {
                        "type": "MSI",
                        "resource": "https://dev.azuresynapse.net/"
                    }
                }
            },
            {
                "name": "If no queued runs then delay",
                "type": "IfCondition",
                "dependsOn": [
                    {
                        "activity": "Update queued run count",
                        "dependencyConditions": [
                            "Succeeded"
                        ]
                    }
                ],
                "userProperties": [],
                "typeProperties": {
                    "expression": {
                        "value": "@greater(length(activity('Initial pipeline queue check').output.value), 0) ",
                        "type": "Expression"
                    },
                    "ifFalseActivities": [
                        {
                            "name": "Delay",
                            "type": "Wait",
                            "dependsOn": [],
                            "userProperties": [],
                            "typeProperties": {
                                "waitTimeInSeconds": {
                                    "value": "@pipeline().parameters.DelayInSeconds",
                                    "type": "Expression"
                                }
                            }
                        },
                        {
                            "name": "Delayed pipeline queue check",
                            "type": "WebActivity",
                            "dependsOn": [
                                {
                                    "activity": "Delay",
                                    "dependencyConditions": [
                                        "Succeeded"
                                    ]
                                }
                            ],
                            "policy": {
                                "timeout": "0.12:00:00",
                                "retry": 0,
                                "retryIntervalInSeconds": 30,
                                "secureOutput": false,
                                "secureInput": false
                            },
                            "userProperties": [],
                            "typeProperties": {
                                "method": "POST",
                                "url": {
                                    "value": "https://@{pipeline().DataFactory}.dev.azuresynapse.net//queryPipelineRuns?api-version=2020-12-01",
                                    "type": "Expression"
                                },
                                "connectVia": {
                                    "referenceName": "AutoResolveIntegrationRuntime",
                                    "type": "IntegrationRuntimeReference"
                                },
                                "body": {
                                    "value": "{\n  \"lastUpdatedAfter\": \"@{pipeline().parameters.CallingPipelineTriggerTime}\",\n  \"lastUpdatedBefore\": \"@{utcNow()}\",\n  \"filters\": [\n    {\n      \"operand\": \"PipelineName\",\n      \"operator\": \"Equals\",\n      \"values\": [\n        \"@{pipeline()?.TriggeredByPipelineName}\"\n      ]\n    },\n    {\n      \"operand\": \"Status\",\n      \"operator\": \"Equals\",\n      \"values\": [\n        \"Queued\"\n      ]\n    }\n  ]\n}\n",
                                    "type": "Expression"
                                },
                                "authentication": {
                                    "type": "MSI",
                                    "resource": "https://dev.azuresynapse.net/"
                                }
                            }
                        },
                        {
                            "name": "Update queued run count again",
                            "type": "SetVariable",
                            "dependsOn": [
                                {
                                    "activity": "Delayed pipeline queue check",
                                    "dependencyConditions": [
                                        "Succeeded"
                                    ]
                                }
                            ],
                            "policy": {
                                "secureOutput": false,
                                "secureInput": false
                            },
                            "userProperties": [],
                            "typeProperties": {
                                "variableName": "QueuedRunCount",
                                "value": {
                                    "value": "@length(activity('Delayed pipeline queue check').output.value) ",
                                    "type": "Expression"
                                }
                            }
                        }
                    ]
                }
            },
            {
                "name": "Update queued run count",
                "type": "SetVariable",
                "dependsOn": [
                    {
                        "activity": "Initial pipeline queue check",
                        "dependencyConditions": [
                            "Succeeded"
                        ]
                    }
                ],
                "policy": {
                    "secureOutput": false,
                    "secureInput": false
                },
                "userProperties": [],
                "typeProperties": {
                    "variableName": "QueuedRunCount",
                    "value": {
                        "value": "@length(activity('Initial pipeline queue check').output.value) ",
                        "type": "Expression"
                    }
                }
            },
            {
                "name": "If any queued runs then cancel execution",
                "type": "IfCondition",
                "dependsOn": [
                    {
                        "activity": "If no queued runs then delay",
                        "dependencyConditions": [
                            "Succeeded"
                        ]
                    }
                ],
                "userProperties": [],
                "typeProperties": {
                    "expression": {
                        "value": "@greater(variables('QueuedRunCount'), 0)",
                        "type": "Expression"
                    },
                    "ifFalseActivities": [
                        {
                            "name": "Continue execution",
                            "type": "SetVariable",
                            "dependsOn": [],
                            "policy": {
                                "secureOutput": false,
                                "secureInput": false
                            },
                            "userProperties": [],
                            "typeProperties": {
                                "variableName": "pipelineReturnValue",
                                "value": [
                                    {
                                        "key": "ContinueExecution",
                                        "value": {
                                            "type": "Boolean",
                                            "content": true
                                        }
                                    }
                                ],
                                "setSystemVariable": true
                            }
                        }
                    ],
                    "ifTrueActivities": [
                        {
                            "name": "Cancel execution",
                            "type": "SetVariable",
                            "dependsOn": [],
                            "policy": {
                                "secureOutput": false,
                                "secureInput": false
                            },
                            "userProperties": [],
                            "typeProperties": {
                                "variableName": "pipelineReturnValue",
                                "value": [
                                    {
                                        "key": "ContinueExecution",
                                        "value": {
                                            "type": "Boolean",
                                            "content": false
                                        }
                                    }
                                ],
                                "setSystemVariable": true
                            }
                        }
                    ]
                }
            }
        ],
        "parameters": {
            "DelayInSeconds": {
                "type": "int",
                "defaultValue": 1
            },
            "CallingPipelineTriggerTime": {
                "type": "string"
            }
        },
        "variables": {
            "QueuedRunCount": {
                "type": "Integer",
                "defaultValue": 0
            }
        },
        "annotations": []
    }
}
</code></pre>
<h2 id="pipeline-definition-example-workload-pipeline-using-the-batched-orchestrator-pattern">Pipeline Definition: Example workload pipeline, using the Batched Orchestrator pattern</h2>
<p>Here's the JSON definition of an example pipeline that uses the batched orchestrator. Note the following important points:</p>
<ol>
<li>Concurrency is set to 1</li>
<li>The return value from executing the Batched Orchestrator Pipeline is checked to determine whether to proceed with the main pipeline workload, or to exit processing.</li>
</ol>
<pre><code class="language-json">{
    "name": "Example Batched Trigger Pipeline",
    "properties": {
        "activities": [
            {
                "name": "Orchestrate Batched Triggers",
                "type": "ExecutePipeline",
                "dependsOn": [],
                "policy": {
                    "secureInput": false
                },
                "userProperties": [],
                "typeProperties": {
                    "pipeline": {
                        "referenceName": "Batched Trigger Orchestrator",
                        "type": "PipelineReference"
                    },
                    "waitOnCompletion": true,
                    "parameters": {
                        "DelayInSeconds": 20,
                        "CallingPipelineTriggerTime": {
                            "value": "@pipeline().TriggerTime",
                            "type": "Expression"
                        }
                    }
                }
            },
            {
                "name": "Check if should continue execution",
                "type": "IfCondition",
                "dependsOn": [
                    {
                        "activity": "Orchestrate Batched Triggers",
                        "dependencyConditions": [
                            "Succeeded"
                        ]
                    }
                ],
                "userProperties": [],
                "typeProperties": {
                    "expression": {
                        "value": "@equals(activity('Orchestrate Batched Triggers').output.pipelineReturnValue.ContinueExecution, true)",
                        "type": "Expression"
                    },
                    "ifFalseActivities": [
                        {
                            "name": "Cancel execution",
                            "type": "SetVariable",
                            "dependsOn": [],
                            "policy": {
                                "secureOutput": false,
                                "secureInput": false
                            },
                            "userProperties": [],
                            "typeProperties": {
                                "variableName": "pipelineReturnValue",
                                "value": [
                                    {
                                        "key": "Result",
                                        "value": {
                                            "type": "String",
                                            "content": "Cancelling execution due to batched trigger orchestration"
                                        }
                                    }
                                ],
                                "setSystemVariable": true
                            }
                        }
                    ],
                    "ifTrueActivities": [
                        {
                            "name": "Do work",
                            "type": "Wait",
                            "dependsOn": [],
                            "userProperties": [],
                            "typeProperties": {
                                "waitTimeInSeconds": 30
                            }
                        }
                    ]
                }
            }
        ],
        "concurrency": 1,
        "variables": {
            "QueuedRunCount": {
                "type": "Integer",
                "defaultValue": 0
            }
        },
        "annotations": []
    }
}
</code></pre>
<h2 id="how-it-works-a-step-by-step-breakdown">How it Works: A Step-by-Step Breakdown</h2>
<ol>
<li>Initial Queue Check:</li>
</ol>
<ul>
<li>The "Initial pipeline queue check" is a <code>WebActivity</code> that queries the Synapse API</li>
<li>It looks for any "Queued" runs of the specific calling pipeline (identified by <code>TriggeredByPipelineName</code>) that started after the calling pipeline was triggered.</li>
</ul>
<ol start="2">
<li>Count Queued Runs:</li>
</ol>
<ul>
<li>The "Update queued run count" <code>SetVariable</code> activity takes the results from the initial API response.</li>
<li>It populates a pipeline variable, <code>QueuedRunCount</code>, with the number of queued instances found.</li>
</ul>
<ol start="3">
<li>Conditional Delay (If No Initial Queue):</li>
</ol>
<ul>
<li>The "If no queued runs, then delay" <code>IfCondition</code> activity evaluates if <code>QueuedRunCount</code> is zero.</li>
<li>If no queued runs are found:
<ul>
<li>It introduces a <code>Delay</code> (a <code>Wait</code> activity) for a configurable <code>DelayInSeconds</code> (defaulting to 1 second). This is how we achieve the micro-batching strategy.</li>
<li>After the delay, a "Delayed pipeline queue check" (another <code>WebActivity</code>) re-queries the Synapse API for queued runs, to see if any other pipeline runs were triggered (and queued) during this delay.</li>
</ul>
</li>
<li>The <code>QueuedRunCount</code> variable is then updated again with this fresh count via "Update queued run count again."</li>
</ul>
<ol start="4">
<li>Continue or Cancel Execution:</li>
</ol>
<ul>
<li>The final "If any queued runs then cancel execution" <code>IfCondition</code> activity makes the decision based on the latest <code>QueuedRunCount</code>.</li>
<li>If <code>QueuedRunCount</code> is greater than zero:
<ul>
<li>The "Cancel execution" <code>SetVariable</code> activity sets the pipeline return variable <code>ContinueExecution</code> to <code>false</code>. This signals to the parent pipeline that it should not proceed, effectively stopping further queuing.</li>
</ul>
</li>
<li>If <code>QueuedRunCount</code> is zero:
<ul>
<li>The "Continue execution" <code>SetVariable</code> activity sets the pipeline return variable <code>ContinueExecution</code> to <code>true</code>. This indicates that the calling pipeline can safely continue its execution, as there are no other instances currently waiting.</li>
</ul>
</li>
</ul>
<h2 id="considerations">Considerations</h2>
<p>The pattern described in this post allows you to decouple event-driven triggering from the actual workload processing. The <code>DelayInSeconds</code> parameter is key to defining your batching window. Experiment with this value to find the right balance between responsiveness and batching efficiency for your specific use case.</p>
<p>Remember, the "Batched Trigger Orchestrator" pipeline <em>orchestrates</em> the running of the parent pipeline, which is where your actual data transformation and workload logic would reside, only executing based on the instruction from the orchestrator.</p>
<p>Full code samples and pipeline template can be found at: <a href="https://github.com/endjin/data-pipeline-patterns">https://github.com/endjin/data-pipeline-patterns</a></p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Data Pipeline Patterns</h3>
        <span class="series-toc__count">3 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/refresh-semantic-model-fabric-pipelines" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Refreshing Semantic Models in Fabric</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/refresh-semantic-model-data-factory-synapse-pipelines" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Refreshing Semantic Models in Synapse</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">3.</span>
                <span class="series-toc__part-title">Batched Event Triggers in Synapse</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Reliably refresh Semantic Model from ADF or Synapse Pipelines</title>
      <description>This post describes a pattern for reliably refreshing Power BI semantic models from Azure Data Factory or Azure Synapse Pipelines.</description>
      <link>https://endjin.com/blog/refresh-semantic-model-data-factory-synapse-pipelines</link>
      <guid isPermaLink="true">https://endjin.com/blog/refresh-semantic-model-data-factory-synapse-pipelines</guid>
      <pubDate>Thu, 02 Oct 2025 06:31:00 GMT</pubDate>
      <category>Data</category>
      <category>Analytics</category>
      <category>Synapse Pipelines</category>
      <category>Azure Data Factory</category>
      <category>Power BI</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/09/refresh-semantic-model-data-factory-synapse-pipelines.png" />
      <dc:creator>James Broome</dc:creator>
      <content:encoded><![CDATA[<p>A common requirement we see in Azure Synapse or Azure Data Factory pipelines is the need to refresh a Power BI semantic model as the final step in a data pipeline. This ensures that as soon as new data is processed, it's reflected in the reports and dashboards that drive actions and decisions.</p>
<p>However, there's no out the box mechanism for doing this in either Azure Synapse or Data Factory (unlike in <a href="https://learn.microsoft.com/en-us/fabric/data-factory/semantic-model-refresh-activity">Microsoft Fabric pipelines</a>). And orchestrating this process introduces a surprising amount of complexity: How do we trigger a refresh programmatically? What happens if multiple pipelines try to refresh the same model at once? How do we monitor the refresh and handle failures gracefully?</p>
<p>In this post, I'll outline a robust and reusable pipeline pattern for Azure Data Factory or Azure Synapse Pipelines that addresses these points. It provides a reliable mechanism for refreshing any semantic model by interacting directly with the Power BI REST API. And if you're using Microsoft Fabric Pipelines, then check out my related post that explains <a href="https://endjin.com/blog/refresh-semantic-model-fabric-pipelines">how to achieve the same result in Fabric</a>.</p>
<hr>
<h3 id="triggering-a-model-refresh-with-web-activity">Triggering a model refresh with Web Activity</h3>
<p>Unlike Microsoft Fabric, neither Azure Data Factory nor Azure Synapse Pipelines have a built-in activity for refreshing a Power BI semantic model. However, it's easy enough to initiate a new refresh with a <strong>Web Activity</strong>, making a <code>POST</code> request to the <a href="https://learn.microsoft.com/en-us/rest/api/power-bi/datasets/refresh-dataset">Power BI REST API's <code>/refreshes</code> endpoint</a>.</p>
<p>While this works for triggering a refresh, it's a "fire and forget" approach. Because the API call is <strong>asynchronous</strong> - it initiates the refresh and immediately returns a <code>202 Accepted</code> response - the calling pipeline has no visibility into whether the refresh actually succeeds or fails. This is pretty significant if having updated reports displaying the latest processed data from your pipeline run is important.</p>
<p>On top of that, the Power BI API will throw an error if you attempt to start a refresh while one is already in progress for the same semantic model. In a busy environment with multiple data feeds and trigger schedules, it's highly likely that two pipelines will eventually attempt to refresh the same model concurrently, causing one to fail. While the built-in activity offers some basic retry settings (as do all pipeline activities), this doesn't provide us with a reliable solution. We don't have a clear understanding of how long the refresh will take, nor do we know how many other processes might be trying to trigger a refresh at the same time.</p>
<hr>
<h3 id="a-more-robust-framework-polling-the-power-bi-rest-api">A More Robust Framework: Polling the Power BI REST API</h3>
<p>A good way to think about a more reliable solution is building a process that is aware of the semantic model's state. We need our pipeline to intelligently check the status, wait if necessary, and then monitor for completion.</p>
<p>The pattern described below uses a sequence of activities to manage this process:</p>
<ol>
<li><strong>Check for an Active Refresh</strong>: Before attempting a new refresh, we must first query the API to see if a refresh is already running.</li>
<li><strong>Trigger the Refresh</strong>: If no refresh is active, we can safely initiate a new one.</li>
<li><strong>Poll for Completion</strong>: After triggering, we must wait for the refresh to complete and confirm its final status.</li>
<li><strong>Handle Success or Failure</strong>: Finally, we either complete the pipeline successfully or fail it with a meaningful error if the refresh did not succeed.</li>
</ol>
<p>To implement this, we'll create a parameterised pipeline that uses the <strong>Workspace ID</strong> and <strong>Dataset ID</strong> (Semantic Model ID) as inputs. The core logic is  built using <code>Web</code>, <code>Until</code>, and <code>If Condition</code> activities.</p>
<p>First, we need to check if a refresh is already in progress. We can do this by wrapping a <code>Web Activity</code> inside an <code>Until</code> loop. The <code>Web Activity</code> makes a <code>GET</code> request to the <code>/refreshes?$top=1</code> endpoint of the Power BI API to fetch the latest refresh status.</p>
<pre><code class="language-json">{
    "name": "Until No Refresh Running",
    "type": "Until",
    "dependsOn": [
        {
            "activity": "Set Refresh Status to Unknown",
            "dependencyConditions": [
                "Succeeded"
            ]
        }
    ],
    "userProperties": [],
    "typeProperties": {
        "expression": {
            "value": "@not(equals('Unknown', variables('Refresh Status')))",
            "type": "Expression"
        },
        "activities": [
            {
                "name": "Check Initial Refresh Status",
                "type": "WebActivity",
                "dependsOn": [],
                "policy": {
                    "timeout": "0.12:00:00",
                    "retry": 0,
                    "retryIntervalInSeconds": 30,
                    "secureOutput": false,
                    "secureInput": false
                },
                "userProperties": [],
                "typeProperties": {
                    "method": "GET",
                    "url": {
                        "value": "https://api.powerbi.com/v1.0/myorg/groups/@{pipeline().parameters.WorkspaceId}/datasets/@{pipeline().parameters.DatasetId}/refreshes?$top=1",
                        "type": "Expression"
                    },
                    "connectVia": {
                        "referenceName": "AutoResolveIntegrationRuntime",
                        "type": "IntegrationRuntimeReference"
                    },
                    "body": {
                        "notifyOption": "NoNotification"
                    },
                    "authentication": {
                        "type": "MSI",
                        "resource": "https://analysis.windows.net/powerbi/api"
                    }
                }
            },
            {
                "name": "Wait 10 Seconds",
                "type": "Wait",
                "dependsOn": [
                    {
                        "activity": "Set Refresh Status",
                        "dependencyConditions": [
                            "Succeeded"
                        ]
                    }
                ],
                "userProperties": [],
                "typeProperties": {
                    "waitTimeInSeconds": 1
                }
            },
            {
                "name": "Set Refresh Status",
                "type": "SetVariable",
                "dependsOn": [
                    {
                        "activity": "Check Initial Refresh Status",
                        "dependencyConditions": [
                            "Succeeded"
                        ]
                    }
                ],
                "policy": {
                    "secureOutput": false,
                    "secureInput": false
                },
                "userProperties": [],
                "typeProperties": {
                    "variableName": "Refresh Status",
                    "value": {
                        "value": "@activity('Check Initial Refresh Status').output.value[0].status",
                        "type": "Expression"
                    }
                }
            }
        ],
        "timeout": "0.12:00:00"
    }
}
</code></pre>
<p>Once the request is made, we can capture the result from the JSON response body and store it in a pipeline variable called <code>RefreshStatus</code>. The <a href="https://learn.microsoft.com/en-us/rest/api/power-bi/datasets/get-refresh-history#refresh">Power BI REST API documentation</a> explains that the status will be <code>"Unknown"</code> if a refresh is actively running, or <code>"Completed"</code> or <code>"Failed"</code> if it has finished. Knowing this, we can wrap the API query in an <code>Until</code> activity loop, polling the API until we get a response that isn't <code>"Unknown"</code>, waiting for 10 seconds between each check. We also need to initialise the <code>RefreshStatus</code> variable to <code>"Unknown"</code> before the loop begins.</p>
<div class="aside"><p>N.B. If you're using a Service Principal or System Assigned Managed Identity for your Web Activity connection, you will need to ensure the correct Contributor permission is applied to the target Power BI workspace to allow querying of refresh statuses.</p>
</div>
<p>Once the loop confirms there is no active refresh, we use another <code>Web Activity</code> to <code>POST</code> to the <code>/refreshes</code> endpoint and trigger a new one. This is followed by a second, identical <code>Until</code> loop that polls for the completion of the refresh we just started.</p>
<pre><code class="language-json">{
    "name": "Trigger Refresh",
    "type": "WebActivity",
    "dependsOn": [
        {
            "activity": "Until No Refresh Running",
            "dependencyConditions": [
                "Succeeded"
            ]
        }
    ],
    "policy": {
        "timeout": "0.12:00:00",
        "retry": 0,
        "retryIntervalInSeconds": 30,
        "secureOutput": false,
        "secureInput": false
    },
    "userProperties": [],
    "typeProperties": {
        "method": "POST",
        "url": {
            "value": "https://api.powerbi.com/v1.0/myorg/groups/@{pipeline().parameters.WorkspaceId}/datasets/@{pipeline().parameters.DatasetId}/refreshes",
            "type": "Expression"
        },
        "connectVia": {
            "referenceName": "AutoResolveIntegrationRuntime",
            "type": "IntegrationRuntimeReference"
        },
        "body": {
            "notifyOption": "NoNotification"
        },
        "authentication": {
            "type": "MSI",
            "resource": "https://analysis.windows.net/powerbi/api"
        }
    }
},
{
    "name": "Poll For Completion",
    "type": "Until",
    "dependsOn": [
        {
            "activity": "Trigger Refresh",
            "dependencyConditions": [
                "Succeeded"
            ]
        }
    ],
    "userProperties": [],
    "typeProperties": {
        "expression": {
            "value": "@not(equals('Unknown', variables('Refresh Status')))",
            "type": "Expression"
        },
        "activities": [
            {
                "name": "Check Refresh Status",
                "type": "WebActivity",
                "dependsOn": [],
                "policy": {
                    "timeout": "0.12:00:00",
                    "retry": 0,
                    "retryIntervalInSeconds": 30,
                    "secureOutput": false,
                    "secureInput": false
                },
                "userProperties": [],
                "typeProperties": {
                    "method": "GET",
                    "url": {
                        "value": "https://api.powerbi.com/v1.0/myorg/groups/@{pipeline().parameters.WorkspaceId}/datasets/@{pipeline().parameters.DatasetId}/refreshes?$top=1",
                        "type": "Expression"
                    },
                    "connectVia": {
                        "referenceName": "AutoResolveIntegrationRuntime",
                        "type": "IntegrationRuntimeReference"
                    },
                    "body": {
                        "notifyOption": "NoNotification"
                    },
                    "authentication": {
                        "type": "MSI",
                        "resource": "https://analysis.windows.net/powerbi/api"
                    }
                }
            },
            {
                "name": "Wait 10s",
                "type": "Wait",
                "dependsOn": [
                    {
                        "activity": "Update Refresh Status",
                        "dependencyConditions": [
                            "Succeeded"
                        ]
                    }
                ],
                "userProperties": [],
                "typeProperties": {
                    "waitTimeInSeconds": 1
                }
            },
            {
                "name": "Update Refresh Status",
                "type": "SetVariable",
                "dependsOn": [
                    {
                        "activity": "Check Refresh Status",
                        "dependencyConditions": [
                            "Succeeded"
                        ]
                    }
                ],
                "policy": {
                    "secureOutput": false,
                    "secureInput": false
                },
                "userProperties": [],
                "typeProperties": {
                    "variableName": "Refresh Status",
                    "value": {
                        "value": "@activity('Check Refresh Status').output.value[0].status",
                        "type": "Expression"
                    }
                }
            }
        ],
        "timeout": "0.12:00:00"
    }
}
</code></pre>
<p>Finally, an <code>If Condition</code> activity checks the final status. If it's <code>"Completed"</code>, the pipeline succeeds. If it's anything else (e.g., <code>"Failed"</code>), we use a <code>Fail</code> activity to stop the pipeline and report the error. Now, a pipeline failure is due to a genuine data processing issue within Power BI, not a conflict with another pipeline run.</p>
<pre><code class="language-json">{
    "name": "Check Status",
    "type": "IfCondition",
    "dependsOn": [
        {
            "activity": "Poll For Completion",
            "dependencyConditions": [
                "Succeeded"
            ]
        }
    ],
    "userProperties": [],
    "typeProperties": {
        "expression": {
            "value": "@equals(variables('Refresh Status'), 'Completed')",
            "type": "Expression"
        },
        "ifFalseActivities": [
            {
                "name": "Dataset Refresh Failed",
                "type": "Fail",
                "dependsOn": [],
                "userProperties": [],
                "typeProperties": {
                    "message": {
                        "value": "@concat('Power BI Dataset refresh failed with status of: ', variables('Refresh Status'))",
                        "type": "Expression"
                    },
                    "errorCode": "500"
                }
            }
        ]
    }
}
</code></pre>
<hr>
<h3 id="wrapping-up">Wrapping Up</h3>
<p>With this pattern in place, we have a parameterised, reusable pipeline that can be called from any other pipeline using an <code>Execute Pipeline</code> activity. It intelligently waits until any previously running refreshes have completed before triggering a new one, monitors the outcome, and provides a clear success or fail result.</p>
<p>I've made this pipeline pattern available as a template, which you can import directly into your own environments. Alternatively, you can use the JSON source code to build it up yourself. All of this can be found in the Pipeline Patterns repository, along with other useful reusable samples.</p>
<p><a href="https://github.com/endjin/data-pipeline-patterns">https://github.com/endjin/data-pipeline-patterns</a></p>
<h2 id="a-quick-note-for-microsoft-fabric-users">A Quick Note for Microsoft Fabric users</h2>
<p>If you're looking to achieve the same result in Microsoft Fabric, the approach is slightly simpler as there's an out-of-the-box <a href="https://learn.microsoft.com/en-us/fabric/data-factory/semantic-model-refresh-activity">Semantic Model Refresh activity</a>. However, the issue with concurrent refreshes still applies so parts of this pattern can be adapted for use in Fabric to achieve the same level of resilience. <a href="https://endjin.com/blog/refresh-semantic-model-fabric-pipelines">I've covered this in a related post</a>!</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Data Pipeline Patterns</h3>
        <span class="series-toc__count">3 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/refresh-semantic-model-fabric-pipelines" class="series-toc__link">
                    <span class="series-toc__part-number">1.</span>
                    <span class="series-toc__part-title">Refreshing Semantic Models in Fabric</span>
                </a>
            </li>
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">2.</span>
                <span class="series-toc__part-title">Refreshing Semantic Models in Synapse</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/batch-triggered-pipeline-runs-azure-synapse" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Batched Event Triggers in Synapse</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Reliably refreshing a Semantic Model from Microsoft Fabric Pipelines</title>
      <description>This post describes a pattern for reliably refreshing Power BI semantic models from Microsoft Fabric Pipelines.</description>
      <link>https://endjin.com/blog/refresh-semantic-model-fabric-pipelines</link>
      <guid isPermaLink="true">https://endjin.com/blog/refresh-semantic-model-fabric-pipelines</guid>
      <pubDate>Thu, 02 Oct 2025 06:30:00 GMT</pubDate>
      <category>Data</category>
      <category>Analytics</category>
      <category>Azure Data Factory</category>
      <category>Microsoft Fabric</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/09/refresh-semantic-model-fabric-pipelines.png" />
      <dc:creator>James Broome</dc:creator>
      <content:encoded><![CDATA[<p>A common requirement we see in Microsoft Fabric pipelines is the need to refresh a Power BI semantic model as the final step in a data pipeline. This ensures that as soon as new data is processed, it's reflected in the reports and dashboards that drive actions and decisions.</p>
<p>Of course, there's a built-in activity for just this purpose in Fabric, and it works pretty well for simple scenarios. However, there's definitely some gotchas to be aware of when you try to use it in real-world situations. In this post, I'm going to describe a more robust and reusable solution for reliably refreshing your semantic models, building on the out-the-box activity with some of the techniques and patterns I've outlined in a related post when implementing the same functionality in Azure Data Factory and Azure Synapse Pipelines.</p>
<h2 id="fabrics-semantic-model-refresh-activity">Fabric's Semantic Model Refresh activity</h2>
<p>Let's start with the <a href="https://learn.microsoft.com/en-us/fabric/data-factory/semantic-model-refresh-activity">Fabric Data Pipeline's Semantic Model Refresh activity</a>. Once it's added to your pipeline, you just point it to an existing semantic model based on the pre-loaded list of workspaces and models that Fabric knows about. For a single, isolated refresh, it works exactly as you'd expect. Behind the scenes, this activity leverages the Power BI REST API to initiate the refresh. The process of triggering a new refresh action is asynchronous, so the activity will automatically poll the API until the refresh is complete. If the refresh succeeds, then so does the activity and your pipeline will continue, or exit successfully. But if the refresh fails, then the activity will throw and error and fail the pipeline run.</p>
<h2 id="triggering-multiple-refreshes-will-fail">Triggering multiple refreshes will fail</h2>
<p>However, consider a scenario where you have multiple pipeline runs attempting to refresh the same model concurrently. This isn't uncommon, with the variety of options around automatically triggering pipeline runs. If data is being updated frequently and your semantic model takes a while to process, then it's highly likely that you'll hit this scenario.</p>
<p>The problem here is that the Power BI API will throw an error if you attempt to trigger a new refresh while one is already in progress, and the Semantic Model Refresh activity doesn't attempt to handle this gracefully. So, whilst you can have multiple pipeline runs executing simultaneously, when it comes to the model refresh step your pipeline is going to error and report a failure.</p>
<p>While the built-in activity offers some basic retry settings (as do all pipeline activities), this doesn't provide us with a reliable solution. We don't have a clear understanding of how long the refresh will take, nor do we know how many other processes might be trying to trigger a refresh at the same time.</p>
<h2 id="a-more-robust-approach-polling-the-power-bi-rest-api">A more robust approach: polling the Power BI REST API</h2>
<p>What we need is a mechanism to reliably trigger a refresh, ensuring that any previous refreshes have completed first. My <a href="https://endjin.com/blog/refresh-semantic-model-data-factory-synapse-pipelines">previous post about doing this in Azure Synapse and Azure Data Factory</a> includes all the pieces we need.</p>
<p>To achieve this, we need to interact with the <a href="https://learn.microsoft.com/en-us/rest/api/power-bi/datasets/get-refresh-history">Power BI REST API directly to query the status of running refreshes</a>. We can do this easily using a simple Web Activity within our Fabric pipeline, parameterising the Workspace ID and the Semantic Model (or Dataset) ID to construct the URL dynamically. We'll issue a GET request to the API, including a query string parameter to filter the results and return only the latest refresh (<code>$top=1</code>).</p>
<pre><code class="language-json">{
    "name": "Check Refresh Status",
    "type": "WebActivity",
    "dependsOn": [],
    "policy": {
        "timeout": "0.12:00:00",
        "retry": 0,
        "retryIntervalInSeconds": 30,
        "secureOutput": false,
        "secureInput": false
    },
    "typeProperties": {
        "method": "GET",
        "relativeUrl": "/v1.0/myorg/groups/@{pipeline().parameters.WorkspaceId}/datasets/@{pipeline().parameters.DatasetId}/refreshes?$top=1"
    },
    "externalReferences": {
        "connection": ""
    }
}
</code></pre>
<div class="aside"><p>The <a href="https://learn.microsoft.com/en-us/fabric/data-factory/web-activity#web-activity-settings">Connections in Microsoft Fabric</a> work slightly differently to Linked Services in Azure Synapse or Azure Data Factory. The code sample above shows a relative Url to the <code>/refreshes</code> endpoint, and the Web Activity will need a configured <code>Web v2</code> Connection, with:</p>
<ul>
<li>The <code>Base Url</code> property pointing to the Power BI REST API Url: <code>https://api.powerbi.com</code></li>
<li>The Token Audience Uri pointing to: <code>https://analysis.windows.net/powerbi/api</code></li>
</ul>
<p>And whichever authentication method you use, you will need to ensure the correct Contributor permission is applied to the workspace to allow querying of refresh statuses.</p>
</div>
<p>Once the request is made, we can capture the result from the JSON response body and store it in a pipeline variable called <code>RefreshStatus</code>. The <a href="https://learn.microsoft.com/en-us/rest/api/power-bi/datasets/get-refresh-history#refresh">Power BI REST API documentation</a> explains that the status will be <code>"Unknown"</code> if a refresh is actively running, or <code>"Completed"</code> or <code>"Failed"</code> if it has finished. Knowing this, we can wrap the API query in an <code>Until</code> activity loop, polling the API until we get a response that isn't <code>"Unknown"</code>, waiting for 10 seconds between each check. We also need to initialise the <code>RefreshStatus</code> variable to <code>"Unknown"</code> before the loop begins.</p>
<pre><code class="language-json">{
    "name": "Set Refresh Status to Unknown",
    "type": "SetVariable",
    "dependsOn": [],
    "policy": {
        "secureOutput": false,
        "secureInput": false
    },
    "typeProperties": {
        "variableName": "Refresh Status",
        "value": "Unknown"
    }
},
{
    "name": "Until No Refresh Running",
    "type": "Until",
    "dependsOn": [
        {
            "activity": "Set Refresh Status to Unknown",
            "dependencyConditions": [
                "Succeeded"
            ]
        }
    ],
    "typeProperties": {
        "expression": {
            "value": "@not(equals('Unknown', variables('Refresh Status')))",
            "type": "Expression"
        },
        "activities": [
            {
                "name": "Wait 10 Seconds",
                "type": "Wait",
                "dependsOn": [
                    {
                        "activity": "Set Refresh Status",
                        "dependencyConditions": [
                            "Succeeded"
                        ]
                    }
                ],
                "typeProperties": {
                    "waitTimeInSeconds": 1
                }
            },
            {
                "name": "Set Refresh Status",
                "type": "SetVariable",
                "dependsOn": [
                    {
                        "activity": "Check Refresh Status",
                        "dependencyConditions": [
                            "Succeeded"
                        ]
                    }
                ],
                "policy": {
                    "secureOutput": false,
                    "secureInput": false
                },
                "typeProperties": {
                    "variableName": "Refresh Status",
                    "value": {
                        "value": "@activity('Check Refresh Status').output.value[0].status",
                        "type": "Expression"
                    }
                }
            },
            {
                "name": "Check Refresh Status",
                "type": "WebActivity",
                "dependsOn": [],
                "policy": {
                    "timeout": "0.12:00:00",
                    "retry": 0,
                    "retryIntervalInSeconds": 30,
                    "secureOutput": false,
                    "secureInput": false
                },
                "typeProperties": {
                    "method": "GET",
                    "relativeUrl": "/v1.0/myorg/groups/@{pipeline().parameters.WorkspaceId}/datasets/@{pipeline().parameters.DatasetId}/refreshes?$top=1"
                },
                "externalReferences": {
                    "connection": ""
                }
            }
        ],
        "timeout": "0.12:00:00"
    }
}
</code></pre>
<p>Once the loop exits - either because there's no actively running refresh, or it's waited for one to finish, we can continue with calling the original Semantic Model Refresh activity safely. We can also re-use the two pipeline parameters (Workspace ID and Semantic Model ID) that we're already using in our Web Activity to parameterise the settings of the Semantic Model Refresh activity, using dynamic content.</p>
<pre><code class="language-json">{
    "name": "Semantic model refresh",
    "type": "PBISemanticModelRefresh",
    "dependsOn": [
        {
            "activity": "Until No Refresh Running",
            "dependencyConditions": [
                "Succeeded"
            ]
        }
    ],
    "policy": {
        "timeout": "0.12:00:00",
        "retry": 0,
        "retryIntervalInSeconds": 30,
        "secureOutput": false,
        "secureInput": false
    },
    "typeProperties": {
        "method": "post",
        "waitOnCompletion": true,
        "commitMode": "Transactional",
        "operationType": "SemanticModelRefresh",
        "groupId": "@{pipeline().parameters.WorkspaceId}",
        "datasetId": "@{pipeline().parameters.DatasetId}"
    },
    "externalReferences": {
        "connection": ""
    }
}
</code></pre>
<h2 id="wrapping-up">Wrapping up</h2>
<p>With this in place, we now have a parameterised pipeline that intelligently waits until any previously running refreshes have completed before triggering a new one. We already know that the Semantic Model Refresh activity will automatically poll and wait for the refresh to finish before it completes, and throw an error and fail the pipeline if the refresh itself fails. However, if it fails now, we know it's because something genuinely went wrong in the refresh, rather than a conflict with triggering another refresh.</p>
<p>This entire pipeline pattern is now reusable. You can call it from any other pipeline using the Execute Pipeline activity whenever you need to reliably refresh a semantic model, simply by passing in the relevant workspace and semantic model IDs.</p>
<p>I've made this pipeline pattern available as a template, which you can import directly into your own Fabric environments. Alternatively, you can use the JSON source code to build it up yourself. All of this can be found in the Pipeline Patterns repository, along with other useful reusable samples.</p>
<p><a href="https://github.com/endjin/data-pipeline-patterns">https://github.com/endjin/data-pipeline-patterns</a></p>
<h2 id="a-quick-note-for-azure-data-factory-and-synapse-pipelines-users">A Quick Note for Azure Data Factory and Synapse Pipelines Users</h2>
<p>If you're looking to achieve the same result in Azure Data Factory or Synapse Pipelines, the approach is slightly different, primarily because there isn't an out-of-the-box Semantic Model Refresh activity. However, with a bit of extra logic (which essentially involves making the API call to trigger the refresh, in addition to polling for its status), you can achieve precisely the same result. <a href="https://endjin.com/blog/refresh-semantic-model-data-factory-synapse-pipelines">I've covered this in detail in a related post</a>!</p>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">Data Pipeline Patterns</h3>
        <span class="series-toc__count">3 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">1.</span>
                <span class="series-toc__part-title">Refreshing Semantic Models in Fabric</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/refresh-semantic-model-data-factory-synapse-pipelines" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Refreshing Semantic Models in Synapse</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/batch-triggered-pipeline-runs-azure-synapse" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Batched Event Triggers in Synapse</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>FabCon Vienna 2025: Day 1</title>
      <description>FabCon is a conference dedicated to everything Microsoft Fabric. Day 1 was mostly focused around the hundreds of new feature announcements.</description>
      <link>https://endjin.com/blog/fabcon-vienna-2025-day-1</link>
      <guid isPermaLink="true">https://endjin.com/blog/fabcon-vienna-2025-day-1</guid>
      <pubDate>Tue, 30 Sep 2025 06:15:00 GMT</pubDate>
      <category>Microsoft Fabric</category>
      <category>FabCon</category>
      <category>Data</category>
      <category>MCP</category>
      <category>Data Factory</category>
      <category>OneLake</category>
      <category>Copilot</category>
      <enclosure length="0" type="image/png" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/09/fabcon-vienna-2025-day-1.png" />
      <dc:creator>Carmel Eve</dc:creator>
      <content:encoded><![CDATA[<p><a href="https://endjin.com/who-we-are/our-people/jessica-hill/">Jess</a> and I recently attended Microsoft FabCon (Vienna) - a twice yearly conference dedicated to Microsoft Fabric. Many topics were explored as part of the conference; unsurprisingly a lot of the content was focused around data and data engineering, but there were also many sessions on monitoring, security, governance, AI, and much more. This highlights the importance and positioning of Fabric as an all-encompassing tool for data engineering and exploration.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/09/me-and-jess-at-fabcon.jpg" alt="Jess and me, standing in front of the FabCon Vienna sign" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/09/me-and-jess-at-fabcon.jpg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/09/me-and-jess-at-fabcon.jpg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/09/me-and-jess-at-fabcon.jpg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/09/me-and-jess-at-fabcon.jpg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>This post will run through day 1 of the conference, which was absolutely full to the brim with announcements.</p>
<h2 id="key-note-microsoft-fabric-the-data-platform-for-the-ai-frontier">Key Note - Microsoft Fabric: The Data Platform for the AI Frontier</h2>
<p>Sadly the Vienna transport system failed us on the first full morning of the conference, meaning that we (and many others) had to walk the ~40 minutes to the conference centre - we'll have to wait and see whether the pilgrimmage of Fabric enthusiasts over the Danube makes it into the history books... But this meant that we missed the first 20 minutes of the Keynote, and when we arrived the announcements were already in full flow.</p>
<h3 id="developer-productivity">Developer Productivity</h3>
<p>The first section that we saw was announcements about developer productivity, including:</p>
<ul>
<li><strong>MCP Servers for Fabric</strong></li>
<li><strong>All of the Fabric items are now supported in Git and CI/CD</strong> - which is a huge step forward for productionising the platform.</li>
<li><strong>Tabulated view</strong>: You can now view multiple items at once, including multiple lakehouses and workspaces - which will make a big difference in productivity. I've definitely been annoyed at having to go and re-open things when moving around in Fabric so far! The tabs for different workspaces will show up in different colours, making it obvious which you're looking at.</li>
<li>You can now use <strong>variable libraries in shortcuts</strong></li>
<li>The <strong>Fabric 2.0 runtime</strong> (including Spark 4.0, and Delta 4.0).</li>
<li><strong>Materialised lakeviews</strong> now with incremental refresh, general perf improvements, and native execution engine</li>
<li><strong>AI functions in Data Wrangler</strong></li>
</ul>
<h3 id="data-warehouse">Data Warehouse</h3>
<ul>
<li>General performance, scale and migration improvements</li>
<li><strong>Warehouse migration assistant</strong> (upload a dacpac)</li>
<li><strong>Graph capabilities</strong> now in Fabric - you can use a no-code view to build up the graph and perform graph-based analysis. It's a shame that at present this feature only exists for data warehouses, and isn't available as a view over lakehouse.</li>
<li>New <strong>Event House endpoint</strong></li>
<li><strong>Real time automated anomaly detection</strong> using ML</li>
</ul>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/09/real-time-anomaly.jpg" alt="Slide about real-time anomaly detection" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/09/real-time-anomaly.jpg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/09/real-time-anomaly.jpg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/09/real-time-anomaly.jpg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/09/real-time-anomaly.jpg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<ul>
<li><strong>User defined functions</strong> for calling store procedures and AI functions</li>
<li>And, you can now <strong>call UDFs directly from Power BI</strong>, passing in measure values. This means that you can have parameterised write-back into your data, and perform analysis based on those values, which could then in turn be used to update the Power BI report. A powerful tool for interactive reporting!</li>
</ul>
<h3 id="data-factory">Data Factory</h3>
<p>There were two announcements around performance:</p>
<ul>
<li><strong>Faster data previews</strong> in Data Factory</li>
<li>And, <strong>Modern Query Evaluator</strong> provides huge performance improvements for Data Flows</li>
</ul>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/09/data-flows-performance.jpg" alt="Data flows performance comparison" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/09/data-flows-performance.jpg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/09/data-flows-performance.jpg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/09/data-flows-performance.jpg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/09/data-flows-performance.jpg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>Alongside this, the prices for Data Flow Gen 2 (CI/CD) have been decreased, using a two-tiered approach:</p>
<ul>
<li>First 10 Minutes: 12 CU (25% reduction)</li>
<li>After that: Drops to 1.5 CU (90% reduction)</li>
</ul>
<p>This combined with the performance improvement (decreasing time-spent) should make a big difference in overall costs.</p>
<h3 id="onelake">OneLake:</h3>
<ul>
<li><strong>New Shortcut endpoints</strong>: Azure Blob and Azure SQL MI,</li>
<li><strong>New Mirroring endpoints</strong>: Oracle (preview), Google Big Query (preview)</li>
<li><strong>OneLake table API</strong>, introducing Iceberg and Delta Lake standard APIs, for greater interoperability, with anything that can understand the new table endpoints.</li>
<li><strong>OneLake storage diagnostics</strong> has been announced, which allows for diagnostics and audit over all data in OneLake, including shortcuts and mirrored databases.</li>
<li><strong>New security centre</strong> (tab) in Microsoft OneLake for increased security management.</li>
</ul>
<p>There was a lot more on this in the OneLake CoreNote later in the day!</p>
<h3 id="power-bi">Power BI:</h3>
<ul>
<li><strong>Power BI modelling in the web is now supported</strong>. This was a theme amongst the Power BI announcements - with the aim of reaching feature parity between the web and desktop experience - opening the foor for MAC users!</li>
<li><strong>Button slicer in GA</strong></li>
<li><strong>Bard visuals in GA</strong></li>
<li><strong>Copilot in Power BI apps (public preview)</strong>. This is a great feature that allows you to use Copilot within your apps. You get returned visuals on-the-fly to answer the questions you ask. And, you can also use this functionality to discover reports within the application that might be of interest given the context.</li>
</ul>
<p>Again, there was more on this in the Power BI CoreNote.</p>
<h3 id="maps-in-fabric">Maps in Fabric</h3>
<p>This is a new Geospatial feature in Fabric used for displaying data on a map. This is separate to the new map visuals in Power BI <del>(though looks to be powered by the same ArcGis technology)</del> (EDIT: It is not powered by the same ArcGis technology!). You can display realtime data, and build up performant map layers. The maps are also very customisable, letting you display a huge breadth of data at once.</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/09/maps-in-Fabric.jpg" alt="An image of a heatmap in Fabric, alongside the key features." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/09/maps-in-Fabric.jpg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/09/maps-in-Fabric.jpg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/09/maps-in-Fabric.jpg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/09/maps-in-Fabric.jpg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<h2 id="corenote-fabric-data-factory-whats-new-and-roadmap">CoreNote - Fabric Data Factory: What's New and Roadmap</h2>
<p>The next session I attended was the Fabric Data Factory CoreNote, where we explored some of the announcements in more detail.</p>
<p>Again, this was mostly running through a list of announcements.</p>
<p>Here is an overview of the roadmap:</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/09/data-factoy-roadmap.jpg" alt="Data Factory roadmap slide" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/09/data-factoy-roadmap.jpg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/09/data-factoy-roadmap.jpg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/09/data-factoy-roadmap.jpg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/09/data-factoy-roadmap.jpg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<p>There were a few announcements around copy jobs, including:</p>
<ul>
<li><strong>New connectors</strong>:
<img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/09/copy-job.jpg" alt="Slide showing new connectors and roadmap in Copy Jobs." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/09/copy-job.jpg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/09/copy-job.jpg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/09/copy-job.jpg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/09/copy-job.jpg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></li>
<li>The new "<strong>Copy Job Activity</strong>" in pipelines</li>
<li>Support for <strong>Change Data Capture in Copy Jobs</strong> (in preview) - meaning that you only replicate changed data from a source.</li>
<li>And, you now have the option to "<strong>Reset Incremental Copy</strong>" - which allows you to perform a full re-ingestion on the next run.</li>
</ul>
<p>Outside of this,</p>
<ul>
<li>The session re-touched on the Data Flow improvements</li>
<li>Went into the new <strong>built-in debugging and testing of expressions</strong> in pipelines - incredibly useful as typos in pipeline expressions can be very hard to spot!</li>
<li>You can also now use <strong>Copilot in Data Factory</strong> to create transformations in Power Query and write pipeline expressions This will massively improve productivity in these environments.</li>
<li>Support for <strong>multiple schedules</strong>, granting much more flexibility in how you trigger your pipelines.</li>
<li>And, the addition of <strong>interval (tumbling window) schedules</strong></li>
</ul>
<p>There was also a section around OneLake, but given I attended the OneLake session later in the day I won't go into detail on that here.</p>
<p>Then they talked about the preview of <strong>Business Process Solutions</strong>, which includes prebuilt models, reports, dashboards, and AI agents for standard business processes, including integrated security and compliance. Currently the solutions focus on:</p>
<ul>
<li>Finance</li>
<li>Sales</li>
<li>Manufacturing</li>
<li>And, procurement</li>
</ul>
<p>I'm interested to see how flexible these models actually are in catering for real-world process analytics.</p>
<p>As mentioned in the keynote, everything is now supported in Git. There is also additional support for Workspace Identity and networking.</p>
<p>Here is a summary slide of the announcments:</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/09/data-factory-announcements.jpg" alt="Data Factory announcements by status" srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/09/data-factory-announcements.jpg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/09/data-factory-announcements.jpg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/09/data-factory-announcements.jpg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/09/data-factory-announcements.jpg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<h2 id="corenote-power-bi-roadmap-strategy-vision-and-demos">CoreNote - Power BI Roadmap: Strategy, Vision and Demos</h2>
<p>As I'm sure you've guessed by now, the CoreNotes throughout the day followed a similar line - exploring the wealth of new features and functionality, so this time, in Power BI...</p>
<h3 id="data-visualisations">Data Visualisations</h3>
<ul>
<li>As mentioned earlier, card visuals are now GA. You can add image heroes and customise them in a lot of different ways, which adds a lot of visually interesting options in reports.</li>
<li>Button slicers are also generally available. This includes partial highlighting based on data selection, and customisation.</li>
<li>There is <strong>additional image support</strong> where you can change the image on hover over or click, which can add an interactive feel to reports.</li>
<li>There are new <strong>Azure Maps features</strong>, coming with the replacement of Bing Maps, which allow you to add path layers and customise the base map.</li>
<li>And, you can now create <strong>organisational themes</strong>, which will have a huge impact for people who need to create many different reports within an organisation whilst preserving a coherent look and feel. Certainly for us we have long term clients for who we have created hundreds of reports. This will mean reduce the manual effort involved in creating new reports, and ensure that theming is completely consistent.</li>
<li><strong>Performance analyser is also now available in the web</strong>, continuing the theme of enabling web feature parity.</li>
</ul>
<h3 id="semantic-modelling">Semantic Modelling</h3>
<ul>
<li>As mentioned earlier, semantic modelling is now available on the web.</li>
<li>Report creation is also now available in the web</li>
<li><strong>Best Practice Analyzer</strong> and <strong>Memory Analyzer</strong> are now generally available. These allow you to use a one-click Fabric notebook to identify improvements to your models.</li>
<li>Modelling over Direct Lake and import models in now available in Power BI desktop and on the web (preview)</li>
<li><strong>Tabular Model Definition Language (TMDL) is now generally available</strong>. This is a code-first way to define your semantic models. Not only does this cater for those of us who would rather use a code-first than visual approach, it is also useful for large-scale model editing.</li>
<li>You can now choose whether you want to <strong>refresh data, schema or both</strong> in Power BI Desktop</li>
<li>And, there have been some exciting DAX improvements:
<ul>
<li>You can now create <strong>User Defined Functions</strong> (preview). This allows you to define custom functions with parameters. As someone from a software development background, seeing DAX move this way is very exciting, paving the way for useful DAX utility libraries and reusable functionality.</li>
<li><strong>Customised time intelligence</strong> - you can now define custom calendars, which enables huge flexibility in time-based analysis (you can, for example, define your calendar to coincide with the Fiscal year). You can also now perform week-based analysis, and work with "sparse date" columns, where not every date is represented.</li>
<li><strong>Copilot in DAX query view is also now GA</strong> - allowing for improved productivity whilst working on these queries.</li>
</ul>
</li>
</ul>
<h3 id="power-bi-copilot">Power BI Copilot</h3>
<p>And then finally, around Copilot more generally:</p>
<p>There was a good demo about using the Copilot chat in a Power BI App. When you use it inside of an application, it is scoped to the data an the reports inside of that app. You can use it to gain visibility on the available data and reports, or ask specific questions, to which the answers may include on-demand visualisations, which can be edited and interacted with inside of the chat.</p>
<p>You are also able to "prep your data for AI" which means to add metadata which allows Copilot (and Fabric Data Agents) to better understand your data. There are a few things you can do:</p>
<ul>
<li>You can remove certain things from Copilot's view</li>
<li>You can add descriptions to the sources in the semantic model, which Copilot will use to understand the context around the data.</li>
<li>You can set up "verified answers" based on certain visuals, so that when users ask specific questions it knows where to get the answer.</li>
<li>And, you can mark data as prepared for AI, so agents and users know that the model has been prepped for these use cases.</li>
</ul>
<p>You can also now do this with Direct Lake.</p>
<p>And, there is now a Power BI Data Agent that you can connect to from M365 Copilot. This means that you can have these data exploration and report discovery conversations (with all the in-place visuals) directly from M365 Copilot.</p>
<h2 id="corenote-unify-your-data-estate-with-onelake-the-onedrive-for-data">CoreNote - Unify your Data Estate with OneLake - the OneDrive for Data</h2>
<p>The next CoreNote was on <a href="https://endjin.com/blog/what-is-onelake">OneLake - the storage technology which powers Fabric</a>.</p>
<p>There was quite a deep dive in to OneLake Catalog, which is a powerful tool for discoverability, tagging, governance etc.</p>
<p>A question I had was how OneLake Catalog related to Purview. Someone asked this exact question and got an answer along the lines: "Purview is an enterprise catalog solution, OneLake Catalog is designed to Catalog all of your data estate. There are integrations between the two, meaning that you can encompass your OneLake Catalog data within your enterprise solution". To understand a bit more about how this works in practice, keep your eyes open for coming deep dive on the topic!</p>
<p>There was then an exploration of shortcut transforms. <a href="https://endjin.com/what-we-think/talks/microsoft-fabric-creating-a-onelake-shortcut-to-adls-gen2">Shortcuts allow you to create a pointer to data stored in another system</a> (within Azure, or elsewhere - e.g. in other clouds such as S3) without replicating the data. Shortcut transforms allow you to automatically apply transforms as part of this process, for example, transforming from CSV to Delta. This specific transform has been available for a little while, but you can also now <strong>transform between Parquet and Delta, and JSON and Delta</strong>, as part of the shortcut. Alongside this, there are also new "<strong>AI Transforms</strong>" which allow you to do things like sentiment analysis without setting up an entire ETL process.</p>
<p>They then focused on how you can use mirroring to bring catalogs together. Mirroring syncs the metadata from whatever service you're using (be that Databricks, Snowflake, etc.), and then, if the data is in an open table format, uses shortcuts to mirror the data itself. For proprietary data, it creates a replica and uses change management to keep it up to date. More on this in day 2, where we attended a deeper dive into shortcuts and mirroring in OneLake!</p>
<p>You can also now use <strong>Fabric Agents over shortcuts <em>and</em> mirrored data</strong> - meaning that you can include this data in your natural language queries, and connect to it from Azure Foundry.</p>
<p>As mentioned earlier, <strong>shortcuts to Azure Blob Storage and Azure SQL MI</strong> are now GA. And, <strong>mirroring support for Oracle and Google Big Query</strong>, and some SAP mirroring, is now in preview.</p>
<p>There is a <strong>new Onelake.Table API</strong>, which is interoperable with Snowflake, and can be used with DuckDB, Spark, and anything that can understand table endpoint.</p>
<p>As mentioned in the KeyNote, there is a new preview of <strong>OneLake Diagnostics</strong>, which allows you to monitor usage for audit and debugging, including for shortcuts and mirrored data.</p>
<p>There is also a new "<strong>OneLake Security Tab</strong>" which lets you view and define security within onelake, including mirrored items. This provides a centralised place for managing users and roles accross your data estate.</p>
<p>A lot of these OneLake announcements are hot off the press, so I'm sure we'll see more information about what's on offer and examples of usage in the coming weeks.</p>
<h2 id="corenote-whats-next-for-fabric-data-engineering-roadmap-and-innovations">CoreNote - What's next for Fabric Data Engineering: Roadmap and Innovations</h2>
<p>The final session of day 1 focused on data engineering.</p>
<h3 id="ingest-connect">Ingest + Connect</h3>
<ul>
<li>We re-touched on shortcut transformations</li>
<li>You can now <strong>add data connections in notebooks</strong>. This involves adding a connection via the UI within the notebook, which then allows you to autogenerate cells containing the connection code. As someone who has to go and look up the syntax for these connections every time, this feels like a great quality of life improvement!</li>
<li>The <strong>OneLake Catalog integration in Spark</strong> lets you explore available items from the UI.</li>
<li>And, they demonstrated using the <strong>Spark Connector for SQL databases</strong> (announced in March) to read/write from SQL databases directly from Spark.</li>
</ul>
<h3 id="configure-scale">Configure + Scale</h3>
<ul>
<li>For setting up new Spark tools, they are introducing the concept of "<strong>Spark Resource Profiles</strong>". Using these, instead of having many settings to understand and fine tune, you'll just answer a series of questions about your workload and the configuration will be fone for you.</li>
<li>Coming soon: <strong>Custom live pools</strong> in Fabric! Until this point, anyone who wanted to use a custom pool rather than the starter pool had to pay a start up cost. With this feature, you'll be able to mark custom pools as "live and configure schedules and deactivation times.</li>
<li><strong>Fabric Runtime 2.0</strong> is being relaeased, which includes Spark and Delta 4.0. Alongside this, they are releasing a feature called "<strong>early runtime access</strong>" where you can try runtimes before they are put through to full release. The idea with this is that you can set up a test environment and migrate that over early. If anything breaks, you can then report this to the Fabric team and they can take this into account before releasing.</li>
<li>There have also been some big performance improvements over the past few months for the <strong>Native Execution Engine</strong>. The headline figure is that, using this engine, you can achieve up to 6X faster performance than using OSS versions of the Spark runtime. You currently need to enable the use of the native execution engine (which I'd definitely recommend doing if you haven't already!), but eventually it will be turned on by default. There are still cases where processing will "fall-back" to normal, but the scenarios the engine can cope with are increasing all the time - for example, CSV support is coming soon!</li>
<li>There also should be big <strong>performance improvements for installations</strong> when publishing environments coming in October, and faster session start up times when you've got <strong>custom libraries</strong> installed!</li>
<li>And, finally, you can now <strong>share sessions across up to 50 notebooks</strong>, rather than the current 5. This is a big win for avoiding those start up times when doing concurrent work in Fabric environments. However, it is worth noting that if you are working in many notebooks concurrently, you need to watch out for "out of memory" exceptions, as the nodes' memory will be shared!</li>
</ul>
<h3 id="transform-model">Transform + Model</h3>
<p>Alongside the "quality of life" improvements in configuration, there have been quite a few improvements specifically for notebooks:</p>
<ul>
<li>You can now see notebook version history across multiple IDEs, allowing for much more flexibility.</li>
<li>User defined functions can be accessed directly from notebooks, they are now discoverable via intellisense, and you can now pass dataframes.</li>
<li>Improved error messaging in Fabric notebooks.</li>
<li>Insights into why Spark pools might be taking longer to set up e.g. Fabric starter pools all in use, rare but a possibility.</li>
</ul>
<p>And, there is a new Spark Monitoring library, and improvements in the monitoring tabs, which allow you to see what's going on in notebooks whilst they are running. You can use the monitoring library from a notebook to get insight into job statistics like run durations, number of queued/rejected/running jobs, and how many cores are in use.</p>
<p>And soon, you'll be able to see resource allocation across a workspace, and across the whole Fabric capacity.</p>
<p>Alongside this,</p>
<ul>
<li>The Notebook utils APIs are now generally available.</li>
<li>They have added much better GeoSpatial support, using the ArcGIS technology, adding 180+ geospatial analytics functions to Fabric Spark.</li>
<li>There are new AI functions available in Data Wrangler.</li>
<li>Python notebooks are now generally available.</li>
<li>Python 3.1.2 is coming soon.</li>
<li>And, you will soon be able to connect to the real-time hub directly from the Notebook object explore.</li>
</ul>
<p>A lot of this is still "coming soon" but they assured that the majority should be done this quarter. You can see that there's a huge wealth of exciting new functionality and features coming, both in the data engineering space, but also in Fabric as a whole!</p>
<h2 id="overall">Overall</h2>
<p>That's a wrap on FabCon Day 1, look out for the next blog which provides a summary of day 2, where both me and Jess attended more technical "deep dive" sessions. Hope you've enjoyed this announcements firehose, I certainly feel excited by the direction that Fabric is going, and am looking forward to exploring some of these new features myself over the coming months!</p>
<p><img loading="lazy" src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/blog/2025/09/me-at-fabcon.jpg" alt="Me holding my conference pass." srcset="https://res.cloudinary.com/endjin/image/upload/f_auto/q_75/c_scale/w_480/assets/images/blog/2025/09/me-at-fabcon.jpg 480w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_65/c_scale/w_800/assets/images/blog/2025/09/me-at-fabcon.jpg 800w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_55/c_scale/w_1200/assets/images/blog/2025/09/me-at-fabcon.jpg 1200w, https://res.cloudinary.com/endjin/image/upload/f_auto/q_45/c_scale/w_1600/assets/images/blog/2025/09/me-at-fabcon.jpg 1600w" sizes="(min-width: 70rem) 62rem, 100vw"></p>
<div class="aside"><p>If you're interested in Microsoft Fabric, why not sign up to our new FREE <a href="https://fabricweekly.info/">Fabric Weekly Newsletter</a>? We also run <a href="https://azureweekly.info/">Azure Weekly</a> and <a href="https://powerbiweekly.info/">Power BI Weekly</a> Newsletters too!</p>
</div>
<aside class="series-toc" aria-label="Series table of contents">
    <div class="series-toc__header">
        <h3 class="series-toc__title">FabCon Vienna 2025</h3>
        <span class="series-toc__count">3 parts</span>
    </div>
    <ol class="series-toc__list">
            <li class="series-toc__item series-toc__item--current" aria-current="page">
                <span class="series-toc__part-number">1.</span>
                <span class="series-toc__part-title">Day 1</span>
                <span class="series-toc__current-label">(you are here)</span>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/fabcon-vienna-2025-day-2" class="series-toc__link">
                    <span class="series-toc__part-number">2.</span>
                    <span class="series-toc__part-title">Day 2</span>
                </a>
            </li>
            <li class="series-toc__item">
                <a href="https://endjin.com/blog/fabcon-vienna-2025-day-3" class="series-toc__link">
                    <span class="series-toc__part-number">3.</span>
                    <span class="series-toc__part-title">Day 3</span>
                </a>
            </li>
    </ol>
</aside>]]></content:encoded>
    </item>
    <item>
      <title>Introduction to Technical Architecture</title>
      <description>&lt;p&gt;In this episode, Carmel delves into the realm of technical architecture, highlighting its importance in software development.&lt;/p&gt;
&lt;h2 id="what-youll-learn"&gt;What You'll Learn&lt;/h2&gt;
&lt;p&gt;Carmel breaks down the often-confused world of architecture types, demonstrating:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Enterprise vs Technical Architecture&lt;/strong&gt; - understanding the strategic versus implementation layers&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The dual nature of software architecture&lt;/strong&gt; - structure as a noun, vision as a verb&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Significant design decisions&lt;/strong&gt; - identifying choices that will be costly to reverse&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Technical leadership principles&lt;/strong&gt; - bridging business requirements and implementation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-cutting concerns&lt;/strong&gt; - managing the relationships between system components&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="key-insights-from-this-episode"&gt;Key Insights from This Episode&lt;/h2&gt;
&lt;p&gt;Discover why technical architecture goes beyond just drawing diagrams:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;How architects decompose solutions into constituent parts whilst maintaining coherence&lt;/li&gt;
&lt;li&gt;Why platform choices, design patterns, and abstraction levels require careful consideration&lt;/li&gt;
&lt;li&gt;The critical role of architects in preventing project fragmentation&lt;/li&gt;
&lt;li&gt;Essential communication skills needed across stakeholder levels&lt;/li&gt;
&lt;li&gt;The balance between current needs and future scalability&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="who-should-watch-this"&gt;Who Should Watch This&lt;/h2&gt;
&lt;p&gt;Whether you're an aspiring architect, a senior developer looking to step up, or a project manager wanting to understand technical decision-making better, this video provides the foundational knowledge you need. Carmel's clear explanations make complex architectural concepts accessible whilst maintaining technical rigour.&lt;/p&gt;
&lt;h2 id="about-this-series"&gt;About This Series&lt;/h2&gt;
&lt;p&gt;This is the first episode in endjin's comprehensive Technical Architecture series. Over the coming weeks, we'll explore architectural drivers, patterns, anti-patterns, and real-world case studies from our extensive project portfolio. We help small teams achieve big things through considered architectural decisions.&lt;/p&gt;
&lt;h2 id="chapters"&gt;Chapters&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=3U3vCBaYEJY&amp;amp;t=0s"&gt;00:00&lt;/a&gt; Introduction to Technical Architecture&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=3U3vCBaYEJY&amp;amp;t=11s"&gt;00:11&lt;/a&gt; Types of Architecture&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=3U3vCBaYEJY&amp;amp;t=29s"&gt;00:29&lt;/a&gt; Focus on Technical Architecture&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=3U3vCBaYEJY&amp;amp;t=42s"&gt;00:42&lt;/a&gt; Simon Brown's Insights on Software Architecture&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=3U3vCBaYEJY&amp;amp;t=97s"&gt;01:37&lt;/a&gt; Role and Responsibilities of a Technical Architect&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=3U3vCBaYEJY&amp;amp;t=147s"&gt;02:27&lt;/a&gt; Conclusion and Next Steps&lt;/li&gt;
&lt;/ul&gt;</description>
      <link>https://endjin.com/what-we-think/talks/introduction-to-technical-architecture</link>
      <guid isPermaLink="true">https://endjin.com/what-we-think/talks/introduction-to-technical-architecture</guid>
      <pubDate>Mon, 18 Aug 2025 05:30:00 GMT</pubDate>
      <category>Technical Architecture</category>
      <category>Software Architecture</category>
      <category>Design Patterns</category>
      <category>ADRs</category>
      <enclosure length="0" type="image/jpeg" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/introduction-to-technical-architecture.jpg" />
      <dc:creator>Carmel Eve</dc:creator>
      <content:encoded><![CDATA[<p>In this episode, Carmel delves into the realm of technical architecture, highlighting its importance in software development.</p>
<h2 id="what-youll-learn">What You'll Learn</h2>
<p>Carmel breaks down the often-confused world of architecture types, demonstrating:</p>
<ul>
<li><strong>Enterprise vs Technical Architecture</strong> - understanding the strategic versus implementation layers</li>
<li><strong>The dual nature of software architecture</strong> - structure as a noun, vision as a verb</li>
<li><strong>Significant design decisions</strong> - identifying choices that will be costly to reverse</li>
<li><strong>Technical leadership principles</strong> - bridging business requirements and implementation</li>
<li><strong>Cross-cutting concerns</strong> - managing the relationships between system components</li>
</ul>
<h2 id="key-insights-from-this-episode">Key Insights from This Episode</h2>
<p>Discover why technical architecture goes beyond just drawing diagrams:</p>
<ul>
<li>How architects decompose solutions into constituent parts whilst maintaining coherence</li>
<li>Why platform choices, design patterns, and abstraction levels require careful consideration</li>
<li>The critical role of architects in preventing project fragmentation</li>
<li>Essential communication skills needed across stakeholder levels</li>
<li>The balance between current needs and future scalability</li>
</ul>
<h2 id="who-should-watch-this">Who Should Watch This</h2>
<p>Whether you're an aspiring architect, a senior developer looking to step up, or a project manager wanting to understand technical decision-making better, this video provides the foundational knowledge you need. Carmel's clear explanations make complex architectural concepts accessible whilst maintaining technical rigour.</p>
<h2 id="about-this-series">About This Series</h2>
<p>This is the first episode in endjin's comprehensive Technical Architecture series. Over the coming weeks, we'll explore architectural drivers, patterns, anti-patterns, and real-world case studies from our extensive project portfolio. We help small teams achieve big things through considered architectural decisions.</p>
<h2 id="chapters">Chapters</h2>
<ul>
<li><a href="https://www.youtube.com/watch?v=3U3vCBaYEJY&amp;t=0s">00:00</a> Introduction to Technical Architecture</li>
<li><a href="https://www.youtube.com/watch?v=3U3vCBaYEJY&amp;t=11s">00:11</a> Types of Architecture</li>
<li><a href="https://www.youtube.com/watch?v=3U3vCBaYEJY&amp;t=29s">00:29</a> Focus on Technical Architecture</li>
<li><a href="https://www.youtube.com/watch?v=3U3vCBaYEJY&amp;t=42s">00:42</a> Simon Brown's Insights on Software Architecture</li>
<li><a href="https://www.youtube.com/watch?v=3U3vCBaYEJY&amp;t=97s">01:37</a> Role and Responsibilities of a Technical Architect</li>
<li><a href="https://www.youtube.com/watch?v=3U3vCBaYEJY&amp;t=147s">02:27</a> Conclusion and Next Steps</li>
</ul>
<p><a href="https://www.youtube.com/watch?v=3U3vCBaYEJY"><img src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/introduction-to-technical-architecture.jpg"></a></p><p>Hello and welcome to a series on technical architecture. In today's video, we'll explore what these types of architecture really mean and why it's crucially important for successful software development.</p>
<p>Firstly, I should start by calling out the fact that there are different types of architecture. Enterprise architecture focuses on strategy rather than code, and it is the strategy for how people, processes, and technology is utilized throughout an organization in order to be effective and efficient. The type of architecture I'll be focusing on in this video is technical architecture. This is a lower level of abstraction, which focuses on the technical detail and covers both software and data architecture.</p>
<p>In Simon Brown's book on software architecture, he states that software architecture has a dual meaning. Firstly, as a noun, it signifies structure. It is the result when solutions are decomposed into their constituent parts, including all cross-cutting concerns and relationships. As a verb, it translates to vision. It means to take the project drivers and turn them into a technical roadmap. And this concept equally applies to data architecture. This is the case for many of the core architecture principles that we'll cover in this series, and it is the reason for the umbrella term "technical architecture."</p>
<p>So technical architecture is about making the significant design decisions—the ones which will be costly if you change your mind about. These can include, but aren't limited to: levels of abstraction, technology choices, platform choice, design approach, and patterns, and loads more.</p>
<p>In this book, Simon Brown also states emphatically that it is the job of the architect to introduce technical leadership into a project. This means being the person who can see the bigger picture, whilst also understanding the technical implications of decisions. The architect has to bridge the gap between business requirements and technical implementation. They guide the team through complex trade-offs and ensure that the chosen direction aligns with both current needs and future scalability. They need to communicate effectively with stakeholders at all levels—from developers who need clear technical guidance, to project managers who need realistic timelines, and business leaders who need to understand the value and risks of any architectural choice.</p>
<p>Without this technical leadership, projects can quickly become fragmented, and individual developers can make isolated decisions that don't contribute to a cohesive whole.</p>
<p>In this video, we have covered a brief introduction to technical architecture and the responsibilities of an architect in a project. In our next video, we'll deep dive into architectural drivers that shape projects, like functional requirements and constraints. These are the foundation that every architect has to understand in order to create an effective solution.</p>
<p>Thanks for listening.</p>]]></content:encoded>
    </item>
    <item>
      <title>Rx.NET Packaging Plan 2025</title>
      <description>&lt;p&gt;&lt;a href="https://endjin.com/who-we-are/our-people/ian-griffiths/"&gt;Ian Griffiths&lt;/a&gt; provides an update on the state of Rx.NET &lt;a href="https://endjin.com/what-we-think/talks/the-state-of-reactive-extensions-for-dotnet-in-2025"&gt;since our last talk&lt;/a&gt; in June. Ian addresses the current choices around packaging Rx, focuses on the &amp;quot;package bloat&amp;quot; problem and discusses the strides we've made in addressing this issue.&lt;/p&gt;
&lt;p&gt;Ian explores the new &amp;quot;&lt;a href="https://github.com/dotnet/reactive/tree/feature/rx-gauntlet/Rx.NET/Test/Gauntlet"&gt;Rx Gauntlet&lt;/a&gt;&amp;quot; test suite designed to validate proposed solutions and highlight the use of automated tests and Power BI reports to identify and solve packaging issues.&lt;/p&gt;
&lt;p&gt;He also compares two primary design options for future Rx versions, weighing their advantages and potential risks.&lt;/p&gt;
&lt;p&gt;Finally, he seeks community feedback to guide our next steps in releasing a stable Rx.NET v7 version.&lt;/p&gt;
&lt;p&gt;You can find the detailed write up of this topic via the GitHub Discussion: &lt;a href="https://github.com/dotnet/reactive/discussions/2211"&gt;Packaging Plans July 2025 #2211&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Contents:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=GSDspWHo0bo&amp;amp;t=0s"&gt;00:00&lt;/a&gt; - Introduction and Update&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=GSDspWHo0bo&amp;amp;t=12s"&gt;00:12&lt;/a&gt; - Addressing the Bloat Problem&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=GSDspWHo0bo&amp;amp;t=94s"&gt;01:34&lt;/a&gt; - Evidence-Based Approach and Prototypes&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=GSDspWHo0bo&amp;amp;t=129s"&gt;02:09&lt;/a&gt; - Introducing RX Gauntlet&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=GSDspWHo0bo&amp;amp;t=176s"&gt;02:56&lt;/a&gt; - Testing and Results&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=GSDspWHo0bo&amp;amp;t=414s"&gt;06:54&lt;/a&gt; - Power BI Report Insights&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=GSDspWHo0bo&amp;amp;t=624s"&gt;10:44&lt;/a&gt; - Design Options and Future Plans&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=GSDspWHo0bo&amp;amp;t=895s"&gt;14:55&lt;/a&gt; - Community Feedback and Next Steps&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This talk was given during endjin's internal weekly &amp;quot;show &amp;amp; tell&amp;quot; meeting, but has been edited for public consumption.&lt;/p&gt;</description>
      <link>https://endjin.com/what-we-think/talks/rxdotnet-packaging-plan-2025</link>
      <guid isPermaLink="true">https://endjin.com/what-we-think/talks/rxdotnet-packaging-plan-2025</guid>
      <pubDate>Mon, 04 Aug 2025 04:30:00 GMT</pubDate>
      <category>Reactive Extensions</category>
      <category>dotnet</category>
      <category>Rx.NET</category>
      <category>NuGet</category>
      <category>Reactive Programming</category>
      <category>ReactiveX</category>
      <category>C#</category>
      <category>Open Source</category>
      <category>.NET Foundation</category>
      <category>Talk</category>
      <enclosure length="0" type="image/jpeg" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/rx-net-packaging-plan-july-2025.jpg" />
      <dc:creator>Ian Griffiths</dc:creator>
      <content:encoded><![CDATA[<p><a href="https://endjin.com/who-we-are/our-people/ian-griffiths/">Ian Griffiths</a> provides an update on the state of Rx.NET <a href="https://endjin.com/what-we-think/talks/the-state-of-reactive-extensions-for-dotnet-in-2025">since our last talk</a> in June. Ian addresses the current choices around packaging Rx, focuses on the "package bloat" problem and discusses the strides we've made in addressing this issue.</p>
<p>Ian explores the new "<a href="https://github.com/dotnet/reactive/tree/feature/rx-gauntlet/Rx.NET/Test/Gauntlet">Rx Gauntlet</a>" test suite designed to validate proposed solutions and highlight the use of automated tests and Power BI reports to identify and solve packaging issues.</p>
<p>He also compares two primary design options for future Rx versions, weighing their advantages and potential risks.</p>
<p>Finally, he seeks community feedback to guide our next steps in releasing a stable Rx.NET v7 version.</p>
<p>You can find the detailed write up of this topic via the GitHub Discussion: <a href="https://github.com/dotnet/reactive/discussions/2211">Packaging Plans July 2025 #2211</a></p>
<p>Contents:</p>
<ul>
<li><a href="https://www.youtube.com/watch?v=GSDspWHo0bo&amp;t=0s">00:00</a> - Introduction and Update</li>
<li><a href="https://www.youtube.com/watch?v=GSDspWHo0bo&amp;t=12s">00:12</a> - Addressing the Bloat Problem</li>
<li><a href="https://www.youtube.com/watch?v=GSDspWHo0bo&amp;t=94s">01:34</a> - Evidence-Based Approach and Prototypes</li>
<li><a href="https://www.youtube.com/watch?v=GSDspWHo0bo&amp;t=129s">02:09</a> - Introducing RX Gauntlet</li>
<li><a href="https://www.youtube.com/watch?v=GSDspWHo0bo&amp;t=176s">02:56</a> - Testing and Results</li>
<li><a href="https://www.youtube.com/watch?v=GSDspWHo0bo&amp;t=414s">06:54</a> - Power BI Report Insights</li>
<li><a href="https://www.youtube.com/watch?v=GSDspWHo0bo&amp;t=624s">10:44</a> - Design Options and Future Plans</li>
<li><a href="https://www.youtube.com/watch?v=GSDspWHo0bo&amp;t=895s">14:55</a> - Community Feedback and Next Steps</li>
</ul>
<p>This talk was given during endjin's internal weekly "show &amp; tell" meeting, but has been edited for public consumption.</p>
<p><a href="https://www.youtube.com/watch?v=GSDspWHo0bo"><img src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/rx-net-packaging-plan-july-2025.jpg"></a></p><p>Back at the start of June, we put out a video about the state of Rx this year. This is kind of an update on what we've done since then.</p>
<p>One of the main areas of focus of that talk was the situation around the bloat problem, which is the thing we call the situation where you've written an application in .NET, it targets Windows, and you are not actually using WPF or Windows Forms, but you get a copy of them anyway because Rx sort of forces that on you if you're doing self-contained deployment.</p>
<p>That's a problem that has caused some projects to walk away from Rx, and we have been trying to work out how best to fix that without breaking everything for everyone else. I talked a bit about our plans last time, and one of the things I mentioned there was a plan to introduce a new way of doing tests, partly so we can avoid this sort of problem in the future, but also so that we can actually validate whether particular design approaches are going to work or not. Rather than just arguing about this, we'll do this, that we'll do that. We've had a slight sort of more practical, more evidence-driven approach because we did actually announce right towards the end of last year, yes, we're moving forward with this plan. And then, hopefully some people in the Rx community pointed out some flaws in that plan.</p>
<p>Fortunately we didn't go ahead and do that. Thank you Rx community for saving our necks there. And so we have moved in a more evidence-based direction. Essentially we've done a couple of things. We have actually gone ahead and built various prototypes to implement various different attempts to solve this problem. And we've written some tooling to run automated tests against those solutions because one of the problems, one of the difficulties with these problems is that there are so many little variations you can test and a lot of the problems only occur in certain, quite specific combinations of scenarios. And so unless you automate the testing, it's incredibly easy to miss things.</p>
<p>We talked last time, I talked last time about this proposed test suite that I called Rx Gauntlet, and that now exists. So if I can actually get the focus in the right window. There we are. So in the Rx repo today, it's currently on a separate branch, the feature/rx-gauntlet branch, but it's in there. You can go and take a look at it. There is in the Rx.NET test folder, there is a gauntlet subfolder, which contains the source code for this suite of tests. And the big difference between this and normal unit tests is that this can reach places unit tests can't reach. It can see problems that emerge not inside the code itself, but in the way that the packaging interacts with other things.</p>
<p>And so the thing that we can do in these tests is we can actually generate new projects on the fly and change their configuration programmatically so we can do a hundred variations. In fact, one of the tests here generates, I think 720 different variations on a theme of the basic theme being, I've written an application that uses two libraries, both of which are using Rx. And there's a huge number of variations on that, some of which run into certain problems on certain designs for how to solve the bloat problem. And so this just runs all of them. So the idea is you come up with the possible solution and you run it through the gauntlet and it will tell you how well it stands up.</p>
<p>We're going for this evidence-based approach where we actually build out the proposed solution and then we run it through the gauntlet and we see what happens. So you can find the actual examples. We've got several branches with various different variations on the packaging in progress. I'm not going to go through them now, but they are described in the announcement here. There's a discussion up on the repo saying what we've done, tells you about the test suite, tells you about the various versions we have prototyped and made available on the public Rx preview package feed. I'll just go to that. This is the preview package feed up on Azure DevOps.</p>
<p>By the way, if you go here, it's possible that you will get an access denied error. That happens if you are logged in to Azure DevOps. What happens is Azure DevOps goes, oh, you're logged in. I will try and show you the version of this page that gives you access to all the things that maintainers of the project can see. And then it goes, but you are not a maintainer of this project and goes, therefore you can't have this. However, if you're not logged in, it shows it to you just fine. Which is kind of odd, but there we are. So if you can't see this, try opening a private browser window and then you should be able to see it.</p>
<p>And so if we look at some of the packages in here, you can see this is the System.Reactive package. There are a bunch of 7.0-preview-something packages, and these ones here are all trying out variations on how we might solve the packaging problems and exactly what these mean. This is all described in the discussion documents, so I'm not going to go into that now.</p>
<p>And in some cases there's also WPF specific packages, Windows Forms specific ones, and depending on which design option we go for, there might also be a new System.Reactive.NET package. This becomes the new main face of Rx. And System.Reactive gets relegated to being a legacy facade. There's two design options. One where that does happen and one where that doesn't, and I'll come back and talk about that in just a minute.</p>
<p>So this gauntlet exists and what it produces is enormous quantities of JSON saying what it found for each of the test cases that you ran. So we basically spit out a thing saying, okay, this one we were trying to build for a target of .NET 8. And in this particular one we turned on the EmitDisabledTransitiveProjectReferences flag. Basically all the settings that go into this. And then we say, did we actually see a copy of WPF or Windows Forms deployed into the output of the app. So we're testing for bloat in this particular case. Actually, there's several different things we test for here.</p>
<p>Gauntlet tests for bloat with direct usage and through transitive references. It also checks for a potential bug that can occur with extension methods if you get certain things wrong in the design. Test for all the things that we know might go wrong with certain kinds of designs. And so you end up with lots and lots and lots and lots of JSON to pour through, which is not particularly easy to find things in. So actually finding the things that tell you something is wrong is a bit like looking for a needle in a haystack. So we have also produced this thing here. This is a Power BI report that sits on top of the data and basically pulls out the things that have the problems for you.</p>
<p>So this page here tries to find all of the versions that suffer from package bloat. Let me just try and narrow this down. So you can see here that Rx versions 5 and 6.0.1s, these are published versions of Rx that are up on there today. They have the bloat problem, so we didn't have it before Rx 5 because it only started to occur when self-contained deployment became a possibility and certain versions of WPF and Windows Forms shipped.</p>
<p>And you can see from this that all of the preview versions that we're trying don't suffer from bloat if you just use Rx directly from your application. That's what this page is showing us. This has gone through all the results and it's just showing us the ones that fail. This shows which things have the plugin bug, which is actually an older bug that was fixed, that was reported back in the Rx 2 days, was fixed in Rx 3.1, and then that fix got reverted. And Rx 4 didn't actually cause a problem at the time, but all subsequent versions have actually suffered from this bug. Our current plan is to not fix this because no one has ever actually complained about this and we think it probably doesn't matter as much now as it used to because the scenarios in which it does occur are much narrower than it used to be. But this does look for that. So if people think that is a problem, we can still use this to find it. At the moment, there isn't really a good way to solve this.</p>
<p>This looks for the extension method bug. So this is the thing that our former proposed workaround ran into. We thought we had a workaround for this, for preventing bloat with the existing published version of Rx, but it turns out you get build errors with certain extension methods under certain situations, and this basically looks for that. And again, this is showing that that problem exists. If you try to disable transitive framework references in Rx version 5 or version 6, it doesn't work. But all the candidate builds we have for fixing the problems, actually, it's okay. You don't get these errors because we have worked out how to work around them.</p>
<p>Now, this Power BI report wasn't the whole story. We also found that with the transitive referenced situation, it was all rather complicated and so we actually found it was helpful to write a notebook to do some more control processing. More to do with the fact that I'm better at notebooks than I am at Power BI. You probably could do it with Power BI, but Notebook was the quickest way to get there, and this kind of gives you ultimately a top level view of how the various options stack up against some of the more subtle problems that can occur when your application may or may not be using Rx directly, but is using it indirectly through other components. And so you can't necessarily control exactly how those things are using it. And I'm not going to go into the details here because we've published this report. You can go and read it if you are interested. But basically you get this kind of color chart showing you that some options work better than others.</p>
<p>That one down the bottom is one of our two design candidates that we're now still thinking of doing. You can see it's green on almost everything with one rather significant red that I'm going to talk about in a moment. And the other one we are contemplating is actually the second row in here, although that's got more yellows in it and does actually have one red there. It turns out it's more of an artifact of how this notebook works. So it's got more yellows than greens, but it doesn't suffer from this big major problem down here. So let's talk about that.</p>
<p>So if I go back to the webpage that has the announcement of what we're doing, it says we've essentially, we've looked, we've got four fully worked out prototypes, but we've essentially boiled it down to two that we think we're going to use. And essentially one of those retains System.Reactive as the main Rx assembly. That's today's main Rx package. It's the main Rx package today, and we want to keep it as the main Rx package in the future if possible, because that's the least disruptive thing to do, and one of these solutions does do that. The other one doesn't. So why are we contemplating one that doesn't? Well, there's a serious issue with what we've had to do to make it work. If we want to retain System.Reactive as the main package, basically we have to play tricks with how the package is created. What we end up doing is creating reference assemblies that deliberately leave out certain problematic bits of the public API. But that public API is present in the runtime assemblies, and so that means you get binary compatibility because everything is there in the runtime assemblies. But some of the problems that we used to have with the workarounds for this go away because the compiler can't see them because it will use the reference assemblies.</p>
<p>This seems to work, but we're kind of unhappy with it because it's a clever trick and the history of applying clever tricks to try and work around packaging problems in Rx does not look good. Basically every time we do it, it turns out we're not as clever as we think. Or more often what happens is that things change and stuff that was just fine when it was introduced goes on to cause problems as a result of other things going on in the ecosystem. And so we actually have a preference to try and keep things simple. Ideally we would be doing nothing weird, nothing unusual. We build Rx in a completely conventional way. And this would minimize the chances of things going odd in the future. But the only way we seem to be able to do that is by introducing a new main package for Rx and turning the existing one into a legacy facade. And that's the only way we can really get a clean, ordinary looking build for the main Rx package.</p>
<p>And so that option, introducing a new main Rx package, has the attraction of being less likely to be a liability in the long run. So that's why we are considering that as the other design option is because it's less likely to break for reasons that aren't even visible yet but that will come to haunt us in the future because that has happened several times before with Rx and we don't want to be the latest in a line of those.</p>
<p>So that's where we're at. We've got these two options and actually there's sort of a fudge option, which is that we do one and then the other. One possibility is that for Rx 7 we do the approach where we don't introduce a new main package. We just keep System.Reactive as the primary package for Rx as it is today. But we use this hack to deal with some of these packaging problems and if that works out fine, then great. And if it turns out to cause problems, then maybe in Rx 8, we could do the thing where we introduce a new main assembly and we relegate System.Reactive to now being just a legacy facade that's there purely for backwards compatibility purposes. We could conceivably do that in two steps. So the debate at this point now really is do we actually just go straight for the solution where we just don't mess around with the main package, or do we see if we can get away with this clever trick on the System.Reactive package so that we minimize the disruption, understanding that we might then discover that actually doesn't work after all, and we have to do the package split in the long run anyway.</p>
<p>So those are the choices before us. Or maybe someone has other ideas. So we've got this place to go and vote on this discussion. If you think there's a thing we've missed, then you can vote for the none of the above option here. This is not a democracy, but this will tell us how people are feeling and we'll take it into account. So we haven't decided yet, but we'd like to decide soon because we would really like to get at least a beta release of Rx 7 out there, and then a proper release as soon as possible so we could finally fix this and get back to doing other things to make Rx better rather than just trying to fix build issues.</p>
<p>So there we are.</p>]]></content:encoded>
    </item>
    <item>
      <title>Medallion Architecture in Practice</title>
      <description>&lt;p&gt;In this episode, Carmel Eve delves deeper into the connection between the medallion architecture and the semantic layer, exploring when data becomes truly production ready.&lt;/p&gt;
&lt;p&gt;Carmel recaps the three tiers of the medallion architecture: raw bronze, cleaned silver, and opinionated gold layers. The focus is placed on the semantic layer, which adds context and meaning to the gold layer data, making it useful for humans, machines, and AI. This semantic layer includes human or machine-friendly names, advanced data types, metadata for governance, and relationships between data objects.&lt;/p&gt;
&lt;p&gt;Carmel also discusses the environments for software development—development, testing, and production—and how this multi-environment system applies to the medallion architecture. Finally, we highlight the advantages of using this pattern, such as supporting multiple use cases, enhancing data lineage, and ensuring data's flexibility and security.&lt;/p&gt;
&lt;h2 id="chapters"&gt;Chapters&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=RHdGb0ZkI7c&amp;amp;t=0s"&gt;00:00&lt;/a&gt; Introduction and Recap&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=RHdGb0ZkI7c&amp;amp;t=6s"&gt;00:06&lt;/a&gt; Understanding the Semantic Layer&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=RHdGb0ZkI7c&amp;amp;t=81s"&gt;01:21&lt;/a&gt; Defining the Semantic Layer&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=RHdGb0ZkI7c&amp;amp;t=182s"&gt;03:02&lt;/a&gt; Data Production Readiness&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=RHdGb0ZkI7c&amp;amp;t=193s"&gt;03:13&lt;/a&gt; Development, Testing, and Production Environments&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=RHdGb0ZkI7c&amp;amp;t=283s"&gt;04:43&lt;/a&gt; Applying the Medallion Architecture&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=RHdGb0ZkI7c&amp;amp;t=570s"&gt;09:30&lt;/a&gt; Conclusion and Key Takeaways&lt;/li&gt;
&lt;/ul&gt;</description>
      <link>https://endjin.com/what-we-think/talks/medallion-architecture-in-practice</link>
      <guid isPermaLink="true">https://endjin.com/what-we-think/talks/medallion-architecture-in-practice</guid>
      <pubDate>Tue, 29 Jul 2025 05:30:00 GMT</pubDate>
      <category>Data Engineering</category>
      <category>Medallion Architecture</category>
      <category>Semantic Layer</category>
      <category>Microsoft Fabric</category>
      <category>Databricks</category>
      <category>Data Lake</category>
      <category>Data Lakehouse</category>
      <category>Gold Layer</category>
      <category>Bronze Layer</category>
      <category>Silver Layer</category>
      <enclosure length="0" type="image/jpeg" url="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/medallion-architecture-in-practice.jpg" />
      <dc:creator>Carmel Eve</dc:creator>
      <content:encoded><![CDATA[<p>In this episode, Carmel Eve delves deeper into the connection between the medallion architecture and the semantic layer, exploring when data becomes truly production ready.</p>
<p>Carmel recaps the three tiers of the medallion architecture: raw bronze, cleaned silver, and opinionated gold layers. The focus is placed on the semantic layer, which adds context and meaning to the gold layer data, making it useful for humans, machines, and AI. This semantic layer includes human or machine-friendly names, advanced data types, metadata for governance, and relationships between data objects.</p>
<p>Carmel also discusses the environments for software development—development, testing, and production—and how this multi-environment system applies to the medallion architecture. Finally, we highlight the advantages of using this pattern, such as supporting multiple use cases, enhancing data lineage, and ensuring data's flexibility and security.</p>
<h2 id="chapters">Chapters</h2>
<ul>
<li><a href="https://www.youtube.com/watch?v=RHdGb0ZkI7c&amp;t=0s">00:00</a> Introduction and Recap</li>
<li><a href="https://www.youtube.com/watch?v=RHdGb0ZkI7c&amp;t=6s">00:06</a> Understanding the Semantic Layer</li>
<li><a href="https://www.youtube.com/watch?v=RHdGb0ZkI7c&amp;t=81s">01:21</a> Defining the Semantic Layer</li>
<li><a href="https://www.youtube.com/watch?v=RHdGb0ZkI7c&amp;t=182s">03:02</a> Data Production Readiness</li>
<li><a href="https://www.youtube.com/watch?v=RHdGb0ZkI7c&amp;t=193s">03:13</a> Development, Testing, and Production Environments</li>
<li><a href="https://www.youtube.com/watch?v=RHdGb0ZkI7c&amp;t=283s">04:43</a> Applying the Medallion Architecture</li>
<li><a href="https://www.youtube.com/watch?v=RHdGb0ZkI7c&amp;t=570s">09:30</a> Conclusion and Key Takeaways</li>
</ul>
<p><a href="https://www.youtube.com/watch?v=RHdGb0ZkI7c"><img src="https://res.cloudinary.com/endjin/image/upload/f_auto/q_80/assets/images/talks/medallion-architecture-in-practice.jpg"></a></p><p>Welcome back. In our previous video, we covered an introduction to the medallion architecture. Now let's understand how it connects to the semantic layer, plus we'll discuss when data becomes truly production-ready.</p>
<p>As we saw last time, after passing through the three tiers of the medallion architecture, we arrived at an opinionated or projected view of the data in our gold layer. This gold layer forms the basis of a semantic layer, but the semantic layer adds additional context and meaning to the tables in the gold layer, which enables humans and, more so these days, machines and AI to understand and engage with data.</p>
<p>So this could include human or machine-friendly names, descriptions, and synonyms of objects like tables and columns. It could include assigning more advanced data types over the primitive types that are used in the gold layer – so saying like this number is a percentage, or a latitude, or a currency, or this string is a city. It could include adding metadata to help you enable effective governance of the gold layer, like data classification tags. And it could mean defining relationships between your objects.</p>
<p>Often the idea of the gold layer is conflated with the semantic layer, but without defining your objects' meaning and relationships, you can't use that data in any meaningful way. The semantic layer usually sits outside of the lakehouse, and its exact form will be dependent on your use case.</p>
<p>A good example of this is Power BI. So when you're creating a report, you import the data from the gold layer, and this forms a part of the model. But you then update column names and types to make the report more readable, and enrich the data, and use the modeling tab to define the relationships between your tables and objects. And only at this point is your semantic model fully defined.</p>
<p>Power BI is a useful example because the Semantic Model is a built-in concept, but your semantic layer could also be defined using other tools like Microsoft Purview or Databricks Unity Catalog. Whatever your use case – be it reporting, analytics, or application development – you're likely to define a semantic layer in order to describe your data and give it meaning.</p>
<p>Each output from the semantic layer is a data product – a valuable asset that should be maintained, versioned, and treated as a fully contained product. You may have multiple data products or versions of each, which are consumed by different use cases or used for different types of analytics.</p>
<p>So overall, we have: data is ingested from on-premise or cloud systems into the raw bronze layer, is then processed to the cleaned and validated silver layer, and projected to the opinionated gold layer. Data is then defined as it moves into the described semantic layer, where it then goes on to serve actionable insights. The bronze, silver, and gold layer usually sit inside of the lakehouse, but the semantic layer often sits outside.</p>
<p>So now on to our next question: when is data production-ready?</p>
<p>In software development, we often talk about different environments, and at endjin we often use a three-tiered approach with development, testing, and production.</p>
<p>So in general, the development environment is where engineers are currently working and is therefore the most volatile. New code will be deployed here, and bugs will often be found during this first stage. Things will be changing rapidly, and if the project is actively being worked on, extremely rapidly. Nothing production-focused or client-facing should ever depend on the development environment, as it's purely a place for developers to make changes, trial solutions, and update things.</p>
<p>Once developers are happy with the changes made, and hopefully those changes have passed some kind of quality gate, they'll be deployed into the testing environment. Here, the code undergoes more rigorous testing in a more controlled environment. There might be additional tests including integration tests or non-functional tests, and anything else that doesn't generally fit into a quick feedback loop that isn't necessary during development. Hopefully at this point, any bugs that have slipped through during the development stage will be found.</p>
<p>Finally, once all the tests have passed and all the validation has been carried out, the code will be deployed into production. This is the live code that your wider solution depends on, and if, for example, you're hosting a client-facing web application, this code will drive your public app, and you would therefore hope that it is reliable and bug-free.</p>
<p>This is a generalized pattern that's applicable in loads of scenarios, but the number of environments and their purposes can vary. For example, you might require a pre-production environment for additional validation, or a specialized QA environment in order to meet regulatory requirements.</p>
<p>So how does this apply to our data design pattern? It's slightly confusing that we now have three tiers in both of these separate but related dimensions. In the medallion architecture, data moves from raw to clean to projected. But alongside this, we still want an environment in which engineers can experiment and change things about data – including the silver and gold tier – without this impacting anything public-facing (our development environment). We also want an environment in which changes can be validated (testing), and an environment in which we can be as sure as possible that data is reliable and can be depended upon (production).</p>
<p>So as such, we can design a system as follows: in each of our environments, we have a bronze, silver, and gold tier. It's clear that we don't want any production systems relying on our bronze raw data, as any new data that arrives needs to be cleaned and validated before it can be used. But as is the case for software development, anything that is end-user facing should be relying on the production environment.</p>
<p>The development environment is where engineers are currently working and is therefore volatile. These engineers will need to update things relating to the silver and gold tier and will need to do so without worrying about affecting these production systems. There could be necessary schema changes, column renames, bugs introduced accidentally in calculated columns, tables accidentally deleted, and much more.</p>
<p>It is worth noting that though the environment is functionally volatile, the data itself may be more tightly controlled. Data needs to be consistent in order to allow for development and testing, and test data might be created specifically to hit edge cases, or fake data used to restrict access to production data. Any changes that are made in the development environment are then validated in the testing environment, and once those quality gates have been passed, they're deployed in production.</p>
<p>And within the production environment, nothing should be depending on the data in the bronze tier, as this data is unvalidated and raw. But once we've cleaned and validated the data, both silver and gold tier should be production-ready. At this point, the data's cleaned, validated, and not subject to unpredictable changes as it is in the development and, to some extent, testing environment.</p>
<p>As I mentioned in my last video, it's often stated that data quality improves as you go through the medallion architecture. However, this statement is flawed. The data in the silver tier is no less production-ready than the gold tier. It is just an unopinionated representation of your data. It might be used to feed machine learning models and data science experiments, and the results of those may well be client-facing. Therefore, data in your silver tier in the production environment is very much production data and should be viewed as such.</p>
<p>So to answer our earlier question: when is data production-ready? When you're in your production environment, data is production-ready when it is in the silver or gold tier.</p>
<p>Overall, the opinionation of our data increases as it moves through the medallion architecture. We start off with data in its completely unaltered raw form, and we end with data that is first cleaned and then structured for a specific use case.</p>
<p>Using medallion architecture, we can:</p>
<ul>
<li>Land and then work with data in any format, whether it is structured, semi-structured, or unstructured</li>
<li>Impose gates that limit data quality issues</li>
<li>Support multiple use cases</li>
<li>Support different workload types, including reporting, machine learning, and data science</li>
<li>Support recreation of tables at any time</li>
<li>Support auditing and data versioning</li>
<li>Allow for historical playback, as you have each version of the raw data saved</li>
<li>Allow for greater agility where, for example, customer change requests can be dealt with by just updating the gold projection</li>
<li>Define data lineage and how data moves from source through processing into consumption</li>
<li>Allow for flexibility and security, with certain groups being given access to different projections with different levels of sensitivity</li>
</ul>
<p>An important point is that there's no hard and fast rules for implementing this pattern. Though the medallion architecture provides us with useful guidance on how to structure our data solutions, there's a lot of nuanced decisions that need to be made. For example: where exactly to draw the lines between the different layers, how much validation is done at each stage, and how much processing is done in the gold storage layer versus how much in Power BI. Each of these require a balancing of performance, flexibility, data copying and storage costs, data volumes, historical data support, security, regulatory requirements, and loads more.</p>
<p>Overall, the combination of this data design pattern and a multi-environment system in which data reliability increases as it's promoted through the environments provides a reliable and flexible architecture that can support many different scenarios.</p>
<p>That wraps up our look at the medallion architecture. We've covered the three tiers, the semantic layer, multi-environment deployment, and key advantages of this approach.</p>
<p>Thanks for watching. I hope you found this valuable.</p>]]></content:encoded>
    </item>
  </channel>
</rss>