<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ryan Giggs</title>
    <description>The latest articles on DEV Community by Ryan Giggs (@derrickryangiggs).</description>
    <link>https://dev.to/derrickryangiggs</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2544697%2F1f321a52-47b1-4034-9cc0-fba8e0b8273c.jpeg</url>
      <title>DEV Community: Ryan Giggs</title>
      <link>https://dev.to/derrickryangiggs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/derrickryangiggs"/>
    <language>en</language>
    <item>
      <title>Containers: The Building Block of Cloud Native Architecture</title>
      <dc:creator>Ryan Giggs</dc:creator>
      <pubDate>Wed, 22 Jul 2026 06:36:02 +0000</pubDate>
      <link>https://dev.to/derrickryangiggs/containers-the-building-block-of-cloud-native-architecture-45ff</link>
      <guid>https://dev.to/derrickryangiggs/containers-the-building-block-of-cloud-native-architecture-45ff</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;How a decades-old Linux idea became the foundation of modern software deployment and why containers solved the problems that VMs couldn't.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Based on cloud native curriculum from the &lt;a href="https://www.linuxfoundation.org" rel="noopener noreferrer"&gt;@Linux Foundation&lt;/a&gt; | CNCF&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Problem Containers Were Built to Solve
&lt;/h2&gt;

&lt;p&gt;Before containers, deploying even a simple Python web application was a multi-layered coordination exercise. You had to align the operating system configuration, the language runtime, extensions, system libraries, networking rules, and third-party connections like databases, all across development, staging, and production environments. One environment ran Python 3.9; another ran 3.11. A library worked on the developer's machine and failed in production. Packages installed globally on one server conflicted with another application running beside it.&lt;/p&gt;

&lt;p&gt;This created significant &lt;strong&gt;operational friction&lt;/strong&gt; between developers, who wrote and tested code in their own environments, and sysadmins, who managed the infrastructure those apps ultimately ran on. Deployment became error-prone. Debugging production issues was painful. Maintaining consistency across servers was a manual, fragile effort.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resource inefficiency&lt;/strong&gt; compounded the problem. To get reproducible, isolated environments, organizations turned to &lt;strong&gt;Virtual Machines (VMs)&lt;/strong&gt;. A VM emulates an entire physical server, including its own operating system and kernel. This worked, but running dozens or hundreds of VMs meant each carried the full overhead of a guest OS: gigabytes of memory and disk per instance, slow boot times, and rising infrastructure costs.&lt;/p&gt;

&lt;p&gt;Containers attacked both problems simultaneously.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Is a Container?
&lt;/h2&gt;

&lt;p&gt;A container bundles an application together with all its dependencies (runtimes, libraries, configuration files, environment variables) into a single portable unit. The environment a developer tests in is exactly the same environment that runs in production. Sysadmins no longer need to manually reconstruct the right conditions on each server. The container carries those conditions with it.&lt;/p&gt;

&lt;p&gt;The key resource efficiency breakthrough is this: &lt;strong&gt;containers share the host machine's kernel&lt;/strong&gt; rather than running their own. This eliminates the full overhead of a guest OS per instance. Containers are lighter and faster than VMs, making it practical to run many isolated environments on the same physical hardware, delivering the isolation benefits of VMs without the cost.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A container is not a lightweight VM. It is an &lt;strong&gt;isolated process&lt;/strong&gt; running on a shared host kernel, with its view of the system restricted by Linux kernel features.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  A Brief History of Container Technology
&lt;/h2&gt;

&lt;p&gt;Container concepts are older than most people realize.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1979 - chroot:&lt;/strong&gt; The earliest predecessor was the &lt;code&gt;chroot&lt;/code&gt; command, introduced in Unix Version 7. It allowed users to isolate a process from the main filesystem by creating a &lt;strong&gt;chroot jail&lt;/strong&gt;, a restricted environment that simulated a new root directory. Within this jail, a process could only access files in its own assigned directory, effectively isolating it from the rest of the system while the files remained physically present on disk. Chroot directories can be created at various points in the filesystem hierarchy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2006 - cgroups:&lt;/strong&gt; Google engineers built "process containers" for internal resource isolation, which were contributed to the Linux kernel and renamed &lt;strong&gt;control groups (cgroups)&lt;/strong&gt;. Merged in Linux 2.6.24 in 2008, cgroups gave the kernel the ability to limit and account for the resource usage of process groups, the foundation of what containers rely on today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2008 - LXC (Linux Containers):&lt;/strong&gt; LXC was the first practical container runtime, combining cgroups and namespaces into a usable tool. It was powerful but complex to operate, with no image standard and a difficult CLI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2013 - Docker:&lt;/strong&gt; Solomon Hykes and the dotCloud team wrapped LXC (and later their own &lt;code&gt;libcontainer&lt;/code&gt;) in a developer-friendly interface. Docker's key innovations were &lt;strong&gt;images&lt;/strong&gt;: portable, layered snapshots of a container's filesystem, and a registry (Docker Hub) to distribute them. For the first time, developers could truly "ship their environment."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2015 - OCI and CNCF:&lt;/strong&gt; As the ecosystem grew and multiple container formats emerged, industry leaders formed the &lt;strong&gt;Open Container Initiative (OCI)&lt;/strong&gt;, co-founded by Docker, CoreOS, and the Linux Foundation, to standardize the container image and runtime specifications. The same year, the CNCF was founded to promote containers and cloud native technologies across the ecosystem. Docker donated its runtime as &lt;strong&gt;runc&lt;/strong&gt;, the reference implementation of the OCI runtime spec. &lt;strong&gt;containerd&lt;/strong&gt; was later split out as the higher-level container lifecycle manager, and is now the standard runtime in production Kubernetes clusters.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Containers Work: The Linux Kernel Primitives
&lt;/h2&gt;

&lt;p&gt;Modern containers rest on two core Linux kernel features: &lt;strong&gt;namespaces&lt;/strong&gt; and &lt;strong&gt;cgroups&lt;/strong&gt;. These are not Docker inventions; they are kernel primitives that Docker and other runtimes made accessible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Namespaces: Isolation
&lt;/h3&gt;

&lt;p&gt;Namespaces give each container its own isolated view of the system. A process inside a namespace cannot see or interfere with resources outside it. The Linux kernel currently provides seven namespaces:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;pid&lt;/strong&gt; - Process IDs. Each container gets its own process ID space, so the first process inside a container sees itself as PID 1, regardless of what's running on the host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;net&lt;/strong&gt; - Network. Each container gets its own network stack: its own IP addresses, routing tables, firewall rules, and ports. Two containers can both listen on port 8080 without conflicting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;mnt&lt;/strong&gt; - Mount points. Abstracts the filesystem view and manages which directories and filesystems a container can see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ipc&lt;/strong&gt; - Interprocess communication. Provides separation of named shared memory segments between containers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;user&lt;/strong&gt; - User and group IDs. Gives containers their own set of user and group IDs, enabling rootless containers where a process runs as root inside the container but as an unprivileged user on the host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;uts&lt;/strong&gt; - Unix Time Sharing. Allows each container to have its own hostname and domain name, independent of the host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;time&lt;/strong&gt; - The newest namespace. Can be used to virtualize the clock of a system, useful for testing or sandboxing time-sensitive workloads.&lt;/p&gt;

&lt;h3&gt;
  
  
  cgroups: Resource Control
&lt;/h3&gt;

&lt;p&gt;While namespaces control what a container can &lt;em&gt;see&lt;/em&gt;, &lt;strong&gt;cgroups (control groups)&lt;/strong&gt; control what it can &lt;em&gt;consume&lt;/em&gt;. They limit and enforce how much CPU, memory, disk I/O, and network bandwidth a container is allowed to use. This prevents a single misbehaving container from starving others on the same host, a critical guarantee in multi-tenant environments.&lt;/p&gt;

&lt;p&gt;Modern Linux systems use &lt;strong&gt;cgroups v2&lt;/strong&gt;, which provides a unified hierarchy and better support for container runtimes like containerd.&lt;/p&gt;

&lt;h3&gt;
  
  
  OverlayFS: Efficient Layered Filesystems
&lt;/h3&gt;

&lt;p&gt;A third important primitive is &lt;strong&gt;OverlayFS&lt;/strong&gt;, which enables Docker's layered image model. Each Docker image is composed of read-only layers stacked on top of each other. When a container runs, a thin writable layer is added on top. Changes are written to this layer only, while the base image layers are shared across all containers that use them, dramatically reducing disk usage and speeding up image pulls.&lt;/p&gt;




&lt;h2&gt;
  
  
  Containers vs. Virtual Machines: The Real Difference
&lt;/h2&gt;

&lt;p&gt;The fundamental distinction is clear once you understand the kernel.&lt;/p&gt;

&lt;p&gt;A VM emulates an entire physical computer. It runs its own full operating system, kernel included, on top of a hypervisor. This provides strong isolation but carries substantial overhead: each VM needs gigabytes of memory just for the OS, and takes minutes to boot.&lt;/p&gt;

&lt;p&gt;A container is just an isolated process on the host. It shares the host's kernel directly. There is no hypervisor layer, no guest OS, no boot sequence. A container starts in milliseconds.&lt;/p&gt;

&lt;p&gt;This shared-kernel model is also where the &lt;strong&gt;security boundary matters&lt;/strong&gt;: if there is a kernel vulnerability, it potentially affects all containers on that host. For high-security multi-tenant workloads like AWS Lambda or Google Cloud Run, providers actually run containers inside microVMs (using tools like Firecracker or gVisor) to get both the speed of containers and the stronger isolation of VMs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Container Runtime Stack
&lt;/h2&gt;

&lt;p&gt;When you run &lt;code&gt;docker run&lt;/code&gt;, several layers of software are involved:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker CLI / Kubernetes&lt;/strong&gt; sends a request to &lt;strong&gt;containerd&lt;/strong&gt; (the high-level container lifecycle manager), which calls &lt;strong&gt;containerd-shim&lt;/strong&gt; (one per container), which calls &lt;strong&gt;runc&lt;/strong&gt; (the low-level OCI runtime that actually creates the container using namespaces and cgroups).&lt;/p&gt;

&lt;p&gt;Understanding this stack matters because Kubernetes dropped direct Docker support in 1.24, moving to containerd as the standard runtime via the &lt;strong&gt;Container Runtime Interface (CRI)&lt;/strong&gt;. Docker is still a perfectly valid developer tool, but in production Kubernetes clusters, containerd is what's running under the hood.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Matters for Cloud Native Development
&lt;/h2&gt;

&lt;p&gt;Containers are the unit of deployment in cloud native systems. Everything built on top, including Kubernetes, service meshes, CI/CD pipelines, and serverless platforms, assumes containers as the baseline.&lt;/p&gt;

&lt;p&gt;Understanding what's inside a container (namespaces, cgroups, OverlayFS) makes you a better engineer. You can debug resource contention issues, reason about security boundaries, understand why two containers can share a port without conflict, and make informed decisions about when to use containers versus microVMs for sensitive workloads.&lt;/p&gt;

&lt;p&gt;The ecosystem has matured significantly: OCI standards mean container images built with Docker run on containerd, Podman, or any OCI-compliant runtime without modification. The "build once, run anywhere" promise that Docker introduced in 2013 is now genuinely delivered by an open, standardized stack.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Learnings from the &lt;a href="https://www.linuxfoundation.org" rel="noopener noreferrer"&gt;Linux Foundation&lt;/a&gt;'s cloud native curriculum.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudnative</category>
      <category>linux</category>
      <category>containers</category>
      <category>docker</category>
    </item>
    <item>
      <title>Autoscaling and Serverless: Scaling Smarter in Cloud Native Systems</title>
      <dc:creator>Ryan Giggs</dc:creator>
      <pubDate>Sat, 18 Jul 2026 09:18:57 +0000</pubDate>
      <link>https://dev.to/derrickryangiggs/autoscaling-and-serverless-scaling-smarter-in-cloud-native-systems-2oa1</link>
      <guid>https://dev.to/derrickryangiggs/autoscaling-and-serverless-scaling-smarter-in-cloud-native-systems-2oa1</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;How modern cloud native platforms dynamically adjust to demand — and how serverless takes that abstraction even further.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Based on cloud native curriculum from the &lt;a href="https://www.linuxfoundation.org" rel="noopener noreferrer"&gt;@Linux Foundation&lt;/a&gt; | CNCF&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What Is Autoscaling?
&lt;/h2&gt;

&lt;p&gt;Autoscaling is the dynamic adjustment of compute resources in response to current demand. Rather than statically provisioning infrastructure for peak load — and paying for it even during quiet periods — autoscaling ensures your system expands when traffic rises and contracts when it falls.&lt;/p&gt;

&lt;p&gt;The primary metrics that drive autoscaling decisions are &lt;strong&gt;CPU utilization&lt;/strong&gt; and &lt;strong&gt;memory consumption&lt;/strong&gt;, though modern systems can scale on virtually any observable signal: queue depth, request latency, custom application metrics, and even external event sources.&lt;/p&gt;

&lt;p&gt;There are two fundamental approaches to scaling: &lt;strong&gt;horizontal&lt;/strong&gt; and &lt;strong&gt;vertical&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Horizontal Scaling (Scale Out)
&lt;/h2&gt;

&lt;p&gt;Horizontal scaling — also called &lt;strong&gt;scaling out&lt;/strong&gt; — describes the process of spawning new compute resources to distribute load. In practice, this means launching new copies of your application: additional pods in Kubernetes, new virtual machine instances, or additional container replicas. When traffic drops, those extra instances are terminated.&lt;/p&gt;

&lt;p&gt;This is the approach most cloud native systems favour. It's highly flexible, works well with stateless services, and doesn't require downtime to apply. It also aligns naturally with microservices architecture, where each service can scale independently based on its own load.&lt;/p&gt;




&lt;h2&gt;
  
  
  Vertical Scaling (Scale Up)
&lt;/h2&gt;

&lt;p&gt;Vertical scaling — &lt;strong&gt;scaling up&lt;/strong&gt; — changes the size of the underlying hardware or container resource allocation. Instead of adding more instances, you give each existing instance more CPU cores or memory.&lt;/p&gt;

&lt;p&gt;Vertical scaling works within certain physical limits. For bare-metal servers, you're constrained by the maximum hardware specs of the machine. For virtual machines and containers, the ceiling is typically the size of the largest available instance type or node. Once you hit that ceiling, vertical scaling alone isn't enough.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key distinction:&lt;/strong&gt; Horizontal scaling adds capacity by adding instances; vertical scaling adds capacity by enlarging existing ones. In most cloud native workloads, horizontal scaling is preferred for flexibility and resilience, with vertical scaling used to right-size individual workloads.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Kubernetes Autoscaling: HPA, VPA, and KEDA
&lt;/h2&gt;

&lt;p&gt;In Kubernetes environments, autoscaling operates at multiple levels simultaneously.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Horizontal Pod Autoscaler (HPA)&lt;/strong&gt; is the most commonly used mechanism. It continuously monitors metrics like CPU and memory through the Kubernetes Metrics Server and adjusts the number of pod replicas accordingly. HPA runs its reconciliation loop every 15 seconds, using the formula: &lt;code&gt;desiredReplicas = ceil(currentReplicas × currentMetric / targetMetric)&lt;/code&gt;. You configure it by specifying a target utilization level and a minimum and maximum replica count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Vertical Pod Autoscaler (VPA)&lt;/strong&gt; takes the opposite approach — rather than adding more pods, it analyses the resource usage of existing containers and adjusts their CPU and memory requests and limits. Where HPA assumes your pods are correctly sized and adds more of them, VPA assumes you have the right number of pods and focuses on getting their individual resource allocation right. One important caveat: running HPA and VPA together on CPU and memory can cause conflicts, since both controllers compete over the same resources. The recommended pattern is to use KEDA on custom metrics if you need both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;KEDA (Kubernetes Event-Driven Autoscaler)&lt;/strong&gt; is a CNCF graduated project that extends HPA by enabling scaling based on external event sources — not just CPU percentages. Need to scale workers based on Kafka queue depth? The number of messages in a Redis list? A custom Prometheus metric? KEDA handles all of these. Under the hood, KEDA dynamically generates an HPA resource and feeds it custom metrics from external event sources — it enhances Kubernetes rather than replacing existing components.&lt;/p&gt;

&lt;p&gt;Used together, these tools can reduce costs by 30–50% compared to statically provisioned clusters while maintaining high service-level objectives.&lt;/p&gt;




&lt;h2&gt;
  
  
  Configuring Autoscaling Correctly
&lt;/h2&gt;

&lt;p&gt;Configuring autoscaling environments requires specifying minimum and maximum instance, VM, or container counts, along with the metric that triggers scaling. Getting this right isn't trivial — it requires running many near-production load tests and analyzing how your application behaves and how load balances across instances as it scales.&lt;/p&gt;

&lt;p&gt;Some common pitfalls to avoid:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Setting &lt;strong&gt;maxReplicas too low&lt;/strong&gt; — if peak traffic demands 100 pods but your ceiling is 20, autoscaling becomes ineffective and the service degrades under load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not defining resource requests&lt;/strong&gt; on containers — HPA cannot calculate percentage usage without requests specified in the container spec.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ignoring stabilization windows&lt;/strong&gt; — without configuring &lt;code&gt;stabilizationWindowSeconds&lt;/code&gt; on scale-down, your HPA may scale up and down repeatedly in response to short spikes, a behaviour called "flapping."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scaling your application and underlying infrastructure properly increases both the availability and resilience of your services — a principle that holds in cloud native environments and traditional ones alike.&lt;/p&gt;




&lt;h2&gt;
  
  
  Serverless: Abstracting Infrastructure Away
&lt;/h2&gt;

&lt;p&gt;If containers and Kubernetes abstract away physical servers, &lt;strong&gt;serverless computing&lt;/strong&gt; abstracts away almost everything else — networks, virtual machines, operating systems, and load balancers. You provide the application code; the cloud provider selects the right environment to run it, allocates resources, scales it, and handles all the operational overhead.&lt;/p&gt;

&lt;p&gt;This dramatically lowers the barrier to deploying applications. A developer building a simple web service or API no longer needs to configure infrastructure — they write and deploy code, and the platform handles the rest.&lt;/p&gt;

&lt;p&gt;All major cloud providers offer commercial serverless runtimes: &lt;strong&gt;AWS Lambda&lt;/strong&gt;, &lt;strong&gt;Google Cloud Functions&lt;/strong&gt;, and &lt;strong&gt;Azure Functions&lt;/strong&gt; are the most widely used. These also include subsets of serverless known as &lt;strong&gt;Function as a Service (FaaS)&lt;/strong&gt;, where individual functions are deployed and invoked on demand rather than running persistently.&lt;/p&gt;

&lt;p&gt;Serverless places particular emphasis on &lt;strong&gt;on-demand provisioning&lt;/strong&gt; — resources are allocated when a function is invoked and released immediately after it completes. This means you pay only for what you actually use, measured in milliseconds of execution time rather than hours of uptime.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Cold Start Problem
&lt;/h3&gt;

&lt;p&gt;One tradeoff worth understanding: serverless functions that haven't been invoked recently may experience a &lt;strong&gt;cold start&lt;/strong&gt; — a brief delay as the platform provisions a new execution environment. For latency-sensitive applications, this is an important design consideration. Techniques like provisioned concurrency (on AWS Lambda) or keeping functions warm with scheduled pings can mitigate this.&lt;/p&gt;




&lt;h2&gt;
  
  
  Knative: Open Source Serverless on Kubernetes
&lt;/h2&gt;

&lt;p&gt;Proprietary serverless offerings from cloud providers are powerful, but they come with a significant downside: &lt;strong&gt;vendor lock-in&lt;/strong&gt;. Moving an application from AWS Lambda to Google Cloud Functions requires rethinking the entire deployment model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Knative&lt;/strong&gt; is the open source answer to this problem. Built on top of Kubernetes, Knative extends existing application platforms with serverless capabilities — allowing you to run serverless workloads on any Kubernetes cluster, whether on-premises, on a public cloud, or in a hybrid environment.&lt;/p&gt;

&lt;p&gt;Knative handles infrastructure concerns including autoscaling (including &lt;strong&gt;scale to zero&lt;/strong&gt;, where idle services consume no resources), routing, event delivery, and container building — letting developers focus on business logic rather than Kubernetes internals.&lt;/p&gt;

&lt;p&gt;In October 2025, Knative officially &lt;strong&gt;graduated as a CNCF project&lt;/strong&gt;, marking its readiness for widespread production use. Major cloud providers and enterprises including Alibaba Cloud and Scaleway have already adopted it to power serverless functions, AI inference models, and scalable automation platforms. The graduation also signals Knative's expanding role in AI workloads — an increasingly important use case as organizations run GPU-accelerated inference on Kubernetes.&lt;/p&gt;




&lt;h2&gt;
  
  
  CloudEvents: Solving the Vendor Lock-In Problem for Events
&lt;/h2&gt;

&lt;p&gt;Many cloud providers and vendors offer proprietary event formats, making it difficult to move event-driven workloads between platforms or integrate across ecosystems.&lt;/p&gt;

&lt;p&gt;To address this, the &lt;strong&gt;CloudEvents&lt;/strong&gt; project was founded by CNCF. It provides a vendor-neutral specification for structuring event data in a common format, regardless of where the event originates or where it's consumed. The core idea is augmentation, not replacement — any existing event message flow can be slightly modified to adopt CloudEvents, meaning CloudEvents-enabled infrastructure can work seamlessly alongside non-CloudEvents infrastructure.&lt;/p&gt;

&lt;p&gt;CloudEvents has developed SDKs in nine programming languages to help developers create and process events. Within the CNCF ecosystem, it is already adopted by Argo, Falco, Harbor, Knative, and Serverless Workflow, making it a foundational piece of the cloud native eventing layer.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why this matters:&lt;/strong&gt; Without a common event format, every integration between services or platforms requires custom glue code. CloudEvents standardizes this, much like how HTTP standardized web communication.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Putting It Together: Autoscaling + Serverless in Practice
&lt;/h2&gt;

&lt;p&gt;These two concepts complement each other well in real-world cloud native architectures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;Kubernetes-based microservice&lt;/strong&gt; might use HPA to scale pods horizontally based on CPU, KEDA to scale workers based on Kafka queue depth, and VPA to right-size resource requests automatically.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;serverless function&lt;/strong&gt; handles event-driven tasks (image processing, notifications, webhooks) without any persistent infrastructure — scaling from zero to thousands of concurrent executions in seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Knative&lt;/strong&gt; bridges these two worlds — bringing serverless behaviour (scale to zero, event-driven execution) to teams who want to stay on Kubernetes without committing to a proprietary FaaS platform.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudEvents&lt;/strong&gt; ensures that events flowing between these components — and across cloud providers — follow a consistent, interoperable format.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Together, these patterns give engineering teams the tools to build systems that are not just scalable, but cost-efficient, resilient, and portable by design.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Learnings from the &lt;a href="https://www.linuxfoundation.org" rel="noopener noreferrer"&gt;Linux Foundation&lt;/a&gt;'s cloud native curriculum.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudnative</category>
      <category>kubernetes</category>
      <category>devops</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Measuring RAG Quality With Hit Rate and MRR — LLM Zoomcamp Module 4</title>
      <dc:creator>Ryan Giggs</dc:creator>
      <pubDate>Sun, 12 Jul 2026 13:01:49 +0000</pubDate>
      <link>https://dev.to/derrickryangiggs/measuring-rag-quality-with-hit-rate-and-mrr-llm-zoomcamp-module-4-4f8e</link>
      <guid>https://dev.to/derrickryangiggs/measuring-rag-quality-with-hit-rate-and-mrr-llm-zoomcamp-module-4-4f8e</guid>
      <description>&lt;p&gt;Most RAG systems ship without a single metric. Module 4 of LLM Zoomcamp fixes that.&lt;/p&gt;

&lt;p&gt;Here's what I built and what the numbers showed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;Knowledge base: 72 course lesson pages pulled from GitHub at a fixed commit so everyone works with the same data.&lt;/p&gt;

&lt;p&gt;Ground truth: 360 questions generated by an LLM — 5 per page, worded differently from the source so keyword overlap doesn't inflate scores.&lt;/p&gt;

&lt;p&gt;Three search methods under test: keyword search with minsearch, vector search with ONNX embeddings, and hybrid search combining both via Reciprocal Rank Fusion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Metrics
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hit Rate&lt;/strong&gt; — did the right page appear in the top 5 results? Binary per question, averaged across 360.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MRR&lt;/strong&gt; — how high up was the right page? First place = 1.0, second = 0.5, third = 0.33. Penalizes systems that find the answer but bury it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;p&gt;Text search: Hit Rate 0.76, MRR 0.59&lt;/p&gt;

&lt;p&gt;Vector search: Hit Rate 0.73, MRR 0.55&lt;/p&gt;

&lt;p&gt;Hybrid search (k=1): Hit Rate 0.84, MRR 0.65&lt;/p&gt;

&lt;p&gt;Text beat vector. Hybrid beat both. Tuning k from 60 to 1 in RRF added measurable MRR improvement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# benchmark any search function in one call
&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text_search&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ground_truth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vector_search&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ground_truth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hybrid_search&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ground_truth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you have a ground truth dataset and an evaluate() function, every search decision becomes quantitative. Change a parameter, re-run, see the delta. No more guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/Derrick-Ryan-Giggs/llm-zoomcamp-2026" rel="noopener noreferrer"&gt;github.com/Derrick-Ryan-Giggs/llm-zoomcamp-2026&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Free course: &lt;a href="https://github.com/DataTalksClub/llm-zoomcamp/" rel="noopener noreferrer"&gt;github.com/DataTalksClub/llm-zoomcamp&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>llm</category>
      <category>rag</category>
    </item>
    <item>
      <title>Why Your AI Works in a Notebook But Fails in Production — LLM Zoomcamp Module 3</title>
      <dc:creator>Ryan Giggs</dc:creator>
      <pubDate>Sun, 05 Jul 2026 13:58:10 +0000</pubDate>
      <link>https://dev.to/derrickryangiggs/why-your-ai-works-in-a-notebook-but-fails-in-production-llm-zoomcamp-module-3-74b</link>
      <guid>https://dev.to/derrickryangiggs/why-your-ai-works-in-a-notebook-but-fails-in-production-llm-zoomcamp-module-3-74b</guid>
      <description>&lt;p&gt;Every developer has seen it: an AI prototype that impresses in demos but breaks the moment real users touch it. The problem is rarely the model. It's everything around the model, how context is prepared, how tools are called, how failures are handled, how costs are tracked.&lt;/p&gt;

&lt;p&gt;Module 3 of LLM Zoomcamp 2026 addresses this gap head-on. Taught by Will Russell from Kestra, this module shifts the conversation from "how do I call an LLM" to "how do I run AI reliably at scale."&lt;/p&gt;

&lt;p&gt;Here's what I learned and what most LLM tutorials never teach you.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Experiment That Changed How I Think About AI
&lt;/h2&gt;

&lt;p&gt;Try this yourself. Open ChatGPT and ask it to generate a Kestra workflow that loads CSV data into BigQuery. You'll get YAML that looks completely reasonable and doesn't work. Wrong plugin types, outdated parameters, invented task names.&lt;/p&gt;

&lt;p&gt;Now try the same prompt in Kestra's AI Copilot. It generates a flow that runs.&lt;/p&gt;

&lt;p&gt;Same model family. Same prompt. Completely different outcome.&lt;/p&gt;

&lt;p&gt;The reason: Kestra's AI Copilot retrieves current plugin documentation before generating. The model has accurate, up-to-date context. ChatGPT is guessing from training data that's months or years old.&lt;/p&gt;

&lt;p&gt;This is &lt;strong&gt;Context Engineering&lt;/strong&gt;, the discipline of giving a model exactly the right information at the right time. It's more impactful than model size, fine-tuning, or prompt tricks.&lt;/p&gt;




&lt;h2&gt;
  
  
  RAG vs No RAG: Measured, Not Assumed
&lt;/h2&gt;

&lt;p&gt;The module includes a concrete experiment with two pre-built flows:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Without RAG&lt;/strong&gt;, the model invents plausible but fabricated Kestra 1.1 features with full confidence. It describes "Declarative I/O with Variables", "Enhanced Task Groups", and other features that sound real but aren't accurate to that release.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With RAG&lt;/strong&gt;, the model retrieves the actual Kestra 1.1 release notes and returns real features: the No-Code Dashboard Editor, Multi-Agent AI Systems, Fix with AI, the Human Task for manual approvals, and improved air-gapped environment support.&lt;/p&gt;

&lt;p&gt;One is a liability in production. The other is actually useful.&lt;/p&gt;




&lt;h2&gt;
  
  
  AI Agents: The LLM Takes the Wheel
&lt;/h2&gt;

&lt;p&gt;The second half of the module moves from RAG to agents. The difference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RAG pipeline&lt;/strong&gt;: you decide when to search, what to search, how many results to retrieve&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI agent&lt;/strong&gt;: the model decides all of that itself&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In Kestra, agents are declared in YAML and the framework handles the agentic loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;research_agent&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;io.kestra.plugin.ai.agent.AIAgent&lt;/span&gt;
  &lt;span class="na"&gt;systemMessage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;You are a technical research assistant.&lt;/span&gt;
    &lt;span class="s"&gt;Use your tools to find accurate information before answering.&lt;/span&gt;
  &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;inputs.question&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent decides whether to call a tool, which tool, with what query, and when it has enough context to stop searching and answer. You get full observability, every tool call, every response, every token logged in the Kestra UI.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tracking What Matters: Token Usage
&lt;/h2&gt;

&lt;p&gt;Most tutorials skip this. The module doesn't.&lt;/p&gt;

&lt;p&gt;Every agent task in Kestra exposes token usage on its output object. The module shows you how to log it explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;log_usage&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;io.kestra.plugin.core.log.Log&lt;/span&gt;
  &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;Output tokens: {{ outputs.agent.tokenUsage.outputTokenCount }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What I measured across runs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Short summary: 63 output tokens&lt;/li&gt;
&lt;li&gt;Long summary: 184 output tokens (~3x more)&lt;/li&gt;
&lt;li&gt;3-sentence vs 1-sentence prompt: ~2x more tokens&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Small wording changes in prompts have real cost implications when running thousands of executions. Monitoring from day one prevents surprises.&lt;/p&gt;




&lt;h2&gt;
  
  
  When Agents Are the Wrong Choice
&lt;/h2&gt;

&lt;p&gt;This is the lesson most AI courses skip: &lt;strong&gt;agents are non-deterministic&lt;/strong&gt;. The same input can produce different tool calls, different search strategies, different answers across runs.&lt;/p&gt;

&lt;p&gt;For creative tasks, research, and open-ended workflows that flexibility is a feature.&lt;/p&gt;

&lt;p&gt;For financial reporting, compliance workflows, audit trails, and regulated industries it's a liability. You need deterministic, repeatable, fully auditable pipelines where every step is predictable and logged.&lt;/p&gt;

&lt;p&gt;Kestra supports both. Knowing when to use which is the actual engineering judgment.&lt;/p&gt;




&lt;h2&gt;
  
  
  My Full Solution
&lt;/h2&gt;

&lt;p&gt;All flows and code are open source:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/Derrick-Ryan-Giggs/llm-zoomcamp-2026" rel="noopener noreferrer"&gt;github.com/Derrick-Ryan-Giggs/llm-zoomcamp-2026&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Take the Course Free
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/DataTalksClub/llm-zoomcamp/" rel="noopener noreferrer"&gt;github.com/DataTalksClub/llm-zoomcamp&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Module 4 covers Evaluation — how to measure whether your RAG system is actually working. Writing about that next.&lt;/p&gt;

</description>
      <category>rag</category>
      <category>agents</category>
      <category>kestra</category>
      <category>datatalksclub</category>
    </item>
    <item>
      <title>I Built a Hybrid Search Engine From Scratch — Here's What I Learned (LLM Zoomcamp 2026, Module 2)</title>
      <dc:creator>Ryan Giggs</dc:creator>
      <pubDate>Fri, 26 Jun 2026 11:49:10 +0000</pubDate>
      <link>https://dev.to/derrickryangiggs/i-built-a-hybrid-search-engine-from-scratch-heres-what-i-learned-llm-zoomcamp-2026-module-2-3jdj</link>
      <guid>https://dev.to/derrickryangiggs/i-built-a-hybrid-search-engine-from-scratch-heres-what-i-learned-llm-zoomcamp-2026-module-2-3jdj</guid>
      <description>&lt;p&gt;I just completed Module 2 of the &lt;strong&gt;LLM Zoomcamp 2026&lt;/strong&gt; by &lt;a href="https://github.com/DataTalksClub/llm-zoomcamp/" rel="noopener noreferrer"&gt;@DataTalksClub&lt;/a&gt; — and this module completely changed how I think about search.&lt;/p&gt;

&lt;p&gt;Module 1 taught me RAG and agentic pipelines. Module 2 taught me that the search step inside RAG matters far more than I realized — and that keyword search is only half the story.&lt;/p&gt;

&lt;p&gt;Here's everything I built and learned.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Is Vector Search and Why Does It Matter?
&lt;/h2&gt;

&lt;p&gt;Traditional keyword search matches words. If you search for "enroll", it finds documents containing "enroll" — but misses documents about "joining", "signing up", or "registration" even if they mean exactly the same thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vector search matches meaning, not words.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every piece of text gets converted into a vector — a list of hundreds of numbers that captures its semantic meaning. Similar meanings produce similar vectors, so you can find relevant documents even when they use completely different words.&lt;/p&gt;

&lt;p&gt;This is the foundation of modern AI-powered search, and it's what makes RAG systems actually work at scale.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Built in Module 2
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Text Embeddings with a Lightweight ONNX Model
&lt;/h3&gt;

&lt;p&gt;Instead of downloading the full PyTorch + CUDA stack (~2GB), I used a lightweight ONNX runtime embedder — same vectors, 30x smaller installation, runs on any CPU:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;embedder&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Embedder&lt;/span&gt;

&lt;span class="n"&gt;embedder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Embedder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# loads Xenova/all-MiniLM-L6-v2 via ONNX
&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;embedder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;How does approximate nearest neighbor search work?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# 384 dimensions
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model produces &lt;strong&gt;384-dimensional vectors&lt;/strong&gt; — each number represents a dimension of meaning in the text.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Vector Search From Scratch with NumPy
&lt;/h3&gt;

&lt;p&gt;Before using any library, I implemented vector search by hand to understand what's happening under the hood:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;

&lt;span class="c1"&gt;# cosine similarity — vectors are normalized, so dot product works directly
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cosine_similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# score all chunks against a query
&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# X is the matrix of all chunk embeddings
&lt;/span&gt;&lt;span class="n"&gt;best_idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is exactly what vector databases like Qdrant and pgvector do internally — just much faster at scale using HNSW indexing.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Chunking Long Documents for Better Retrieval
&lt;/h3&gt;

&lt;p&gt;Full pages are too long and dilute the embedding — a match buried deep in a 10,000-character page still pulls in the whole page. The fix is chunking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;gitsource&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;chunk_documents&lt;/span&gt;
&lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;chunk_documents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# 72 pages → 295 overlapping chunks
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Overlapping chunks (step &amp;lt; size) ensure sentences at boundaries don't get cut off. After chunking, retrieval becomes far more precise.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Vector Search with minsearch
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;minsearch&lt;/code&gt; now has a &lt;code&gt;VectorSearch&lt;/code&gt; class that wraps the numpy math into a clean interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;minsearch&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;VectorSearch&lt;/span&gt;

&lt;span class="n"&gt;vector_index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;VectorSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keyword_fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;filename&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;vector_index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vector_index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;num_results&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. Comparing Keyword vs Vector Search
&lt;/h3&gt;

&lt;p&gt;For the query &lt;strong&gt;"How do I store vectors in PostgreSQL?"&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keyword search&lt;/strong&gt; — missed &lt;code&gt;08-pgvector.md&lt;/code&gt; entirely because "pgvector" wasn't in the query&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vector search&lt;/strong&gt; — ranked &lt;code&gt;08-pgvector.md&lt;/code&gt; first because it understood the semantic connection between "store vectors" and "pgvector"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the key insight: vector search finds meaning, keyword search finds words.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Hybrid Search with Reciprocal Rank Fusion (RRF)
&lt;/h3&gt;

&lt;p&gt;Neither approach is perfect on its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Vector search can miss exact terms, names, and rare keywords&lt;/li&gt;
&lt;li&gt;Keyword search misses paraphrases and synonyms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The solution is &lt;strong&gt;hybrid search&lt;/strong&gt; — run both and merge the results using RRF:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rrf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result_lists&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;num_results&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result_lists&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;filename&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;
    &lt;span class="n"&gt;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;num_results&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;

&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rrf&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;vector_results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text_results&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RRF ignores raw scores (which live on different scales) and only looks at rank position. A document that ranks well in both lists beats one that's only strong in a single list — even if it wasn't first in either.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Embeddings capture meaning, not words.&lt;/strong&gt; "Enroll" and "join" produce similar vectors. "Pizza" and "enrollment" don't. This is what makes semantic search powerful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Chunking is not optional.&lt;/strong&gt; Full pages dilute embeddings. 2,000-character overlapping chunks dramatically improve retrieval precision and cut LLM input tokens by 3x.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Neither keyword nor vector search is best.&lt;/strong&gt; Use hybrid search (RRF) in production. It consistently outperforms either approach alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. ONNX makes embeddings practical anywhere.&lt;/strong&gt; No GPU, no PyTorch, no CUDA. 67MB download, runs on a basic laptop. There's no reason not to use vector search even in constrained environments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. The right search approach depends on your data.&lt;/strong&gt; Vector search wins for semantic queries. Keyword search wins for exact terms (names, codes, IDs). Hybrid wins most of the time — but measure to be sure.&lt;/p&gt;




&lt;h2&gt;
  
  
  My Homework Solution
&lt;/h2&gt;

&lt;p&gt;All my code for Module 2 is open source:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/Derrick-Ryan-Giggs/llm-zoomcamp-2026" rel="noopener noreferrer"&gt;github.com/Derrick-Ryan-Giggs/llm-zoomcamp-2026&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;vector-search.ipynb&lt;/code&gt; — embeddings, Qdrant, and vector RAG pipeline&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Vector Search Homework.ipynb&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Want to Learn Too?
&lt;/h2&gt;

&lt;p&gt;LLM Zoomcamp is &lt;strong&gt;completely free&lt;/strong&gt; — no paywall, no certificate fees.&lt;/p&gt;

&lt;p&gt;Sign up: &lt;a href="https://github.com/DataTalksClub/llm-zoomcamp/" rel="noopener noreferrer"&gt;github.com/DataTalksClub/llm-zoomcamp&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Are you working through LLM Zoomcamp 2026? Drop a comment — I'd love to compare notes.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>llm</category>
      <category>vectordatabase</category>
      <category>datatalksclub</category>
    </item>
    <item>
      <title>Cloud Native Architecture Fundamentals: Building for the Modern Era</title>
      <dc:creator>Ryan Giggs</dc:creator>
      <pubDate>Thu, 25 Jun 2026 09:06:34 +0000</pubDate>
      <link>https://dev.to/derrickryangiggs/cloud-native-architecture-fundamentals-building-for-the-modern-era-15lo</link>
      <guid>https://dev.to/derrickryangiggs/cloud-native-architecture-fundamentals-building-for-the-modern-era-15lo</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Optimize your software for cost, efficiency, reliability, and faster delivery — by combining the right technological and architectural patterns.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Based on cloud native curriculum from the &lt;a href="https://www.linuxfoundation.org" rel="noopener noreferrer"&gt;@Linux Foundation&lt;/a&gt; | CNCF&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What Is Cloud Native?
&lt;/h2&gt;

&lt;p&gt;The Cloud Native Computing Foundation (CNCF) defines cloud native as a set of practices that empower organizations to develop, build, and deploy workloads in computing environments — public, private, and hybrid cloud — to meet organizational needs at scale in a programmatic and repeatable manner.&lt;/p&gt;

&lt;p&gt;Cloud native systems are characterized by being &lt;strong&gt;loosely coupled&lt;/strong&gt;, interoperating in a manner that is &lt;strong&gt;secure, resilient, manageable, sustainable, and observable&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In practice, cloud native architectures typically consist of some combination of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Containers&lt;/li&gt;
&lt;li&gt;Service meshes&lt;/li&gt;
&lt;li&gt;Microservices&lt;/li&gt;
&lt;li&gt;Immutable infrastructure&lt;/li&gt;
&lt;li&gt;Serverless functions&lt;/li&gt;
&lt;li&gt;Declarative APIs&lt;/li&gt;
&lt;li&gt;Multi-tenancy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the ecosystem is thriving. According to the &lt;a href="https://www.cncf.io/announcements/2025/04/01/cncf-research-reveals-how-cloud-native-technology-is-reshaping-global-business-and-innovation/" rel="noopener noreferrer"&gt;CNCF 2024 Annual Survey&lt;/a&gt;, cloud native adoption has reached an all-time high of &lt;strong&gt;89%&lt;/strong&gt; among surveyed organizations, with Kubernetes being used, piloted, or evaluated by &lt;strong&gt;93%&lt;/strong&gt; of those organizations.&lt;/p&gt;




&lt;h2&gt;
  
  
  Monolith vs. Cloud Native: Why the Shift?
&lt;/h2&gt;

&lt;p&gt;Traditional monolithic applications bundle all functionality into a single deployable unit. This works fine at small scale — but as user demand grows and teams expand, a monolith becomes a bottleneck. Every change risks the whole system, scaling requires duplicating everything, and a single bug can bring the entire application down.&lt;/p&gt;

&lt;p&gt;Cloud native architecture addresses this by &lt;strong&gt;breaking the application into smaller, independently deployable units&lt;/strong&gt; — each with a clearly defined scope of function. These are called &lt;strong&gt;microservices&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Instead of one giant app, you could have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;UI service&lt;/strong&gt; rendering the frontend&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;checkout service&lt;/strong&gt; handling payments&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;notifications service&lt;/strong&gt; managing alerts&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;inventory service&lt;/strong&gt; tracking stock&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each microservice communicates with the others over a network, typically via REST APIs or message queues. They can be developed, deployed, scaled, and updated independently — by separate teams, on separate schedules.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Monolith → Microservices&lt;/strong&gt; is not just a technical shift. It's a business transformation that directly impacts how fast you can ship, how resilient your product is, and how efficiently your teams operate.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Core Technologies in Cloud Native
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Containers
&lt;/h3&gt;

&lt;p&gt;Containers package your application and all its dependencies into a single portable unit. They ensure the app runs consistently across development, staging, and production environments, eliminating the classic "it works on my machine" problem. Docker popularized this model; containerd is now the standard runtime in production Kubernetes clusters.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kubernetes (K8s)
&lt;/h3&gt;

&lt;p&gt;Kubernetes is the de facto orchestration platform for containers. It automates deployment, scaling, and management of containerized applications. Advanced Kubernetes usage, including auto-scaling with the Horizontal Pod Autoscaler (HPA) and Vertical Pod Autoscaler (VPA), has been shown to &lt;a href="https://medium.com/@reiqwan/cloud-native-architecture-patterns-for-2025-building-enterprise-systems-that-scale-7c465142aaa4" rel="noopener noreferrer"&gt;increase deployment efficiency by 55%&lt;/a&gt; according to CNCF data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Service Meshes
&lt;/h3&gt;

&lt;p&gt;A service mesh manages secure, reliable communication between microservices — handling load balancing, retries, circuit breaking, and mutual TLS (mTLS) encryption. Tools like &lt;strong&gt;Istio&lt;/strong&gt; and &lt;strong&gt;Linkerd&lt;/strong&gt; abstract these concerns away from your application code.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;2025 trend watch:&lt;/strong&gt; Service mesh is evolving away from the resource-heavy sidecar-per-pod model. Istio's &lt;strong&gt;ambient mode&lt;/strong&gt; now uses a node-level proxy (ztunnel) instead, dramatically reducing overhead and making mesh adoption more practical for teams previously deterred by operational complexity.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Serverless
&lt;/h3&gt;

&lt;p&gt;Serverless takes abstraction further — you write code, the cloud provider handles all underlying infrastructure. Functions-as-a-Service (FaaS) platforms like AWS Lambda, Azure Functions, and Google Cloud Functions are ideal for event-driven, unpredictable workloads. Plans for serverless adoption are growing, with the share of companies running no serverless platforms expected to drop significantly through 2025.&lt;/p&gt;

&lt;h3&gt;
  
  
  Declarative APIs &amp;amp; Infrastructure-as-Code (IaC)
&lt;/h3&gt;

&lt;p&gt;Rather than imperatively telling systems &lt;em&gt;how&lt;/em&gt; to do something, you declare &lt;em&gt;what&lt;/em&gt; the desired state should be. Tools like &lt;strong&gt;Terraform&lt;/strong&gt;, &lt;strong&gt;Helm&lt;/strong&gt;, and &lt;strong&gt;Crossplane&lt;/strong&gt; allow teams to version-control infrastructure alongside application code — making environments reproducible and auditable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key Characteristics of Cloud Native Architecture
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. High-Level Automation
&lt;/h3&gt;

&lt;p&gt;To manage all the moving parts of a cloud native system, automation is critical at every stage — from development to production. CI/CD pipelines (using tools like GitHub Actions, ArgoCD, or Flux) enable fast, reliable delivery and make disaster recovery dramatically easier — if you must rebuild your entire system, your pipeline can do it reproducibly from source.&lt;/p&gt;

&lt;p&gt;A related practice worth knowing: &lt;strong&gt;GitOps&lt;/strong&gt;, where Git is the single source of truth for both application and infrastructure state. GitOps-based tools like ArgoCD continuously reconcile your running environment against what's declared in a repository, reducing configuration drift and enabling fast, auditable rollbacks.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Self-Healing
&lt;/h3&gt;

&lt;p&gt;Failures happen — this is expected and designed for. Cloud native applications embed &lt;strong&gt;health checks&lt;/strong&gt; that allow orchestration platforms like Kubernetes to automatically detect unhealthy containers and restart or replace them, often without any human intervention. Your uptime doesn't depend on someone being awake at 3am.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Scalability
&lt;/h3&gt;

&lt;p&gt;Cloud native applications are designed to &lt;strong&gt;scale horizontally&lt;/strong&gt; — spinning up more instances of a service under load, rather than upgrading a single server. Kubernetes' HPA can trigger this automatically based on real-time CPU, memory, or custom metrics. This elasticity means you always have the capacity to handle demand, without permanently over-provisioning.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Scaling goes both ways. Scaling &lt;em&gt;down&lt;/em&gt; during low-traffic periods — combined with usage-based pricing from cloud providers — is a major source of cost savings.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  4. Cost Efficiency
&lt;/h3&gt;

&lt;p&gt;Orchestration systems like Kubernetes are excellent at &lt;strong&gt;bin-packing&lt;/strong&gt; — placing workloads efficiently across your compute fleet to minimize waste. Combined with autoscaling and cloud pricing models, cloud native architectures allow you to pay for what you actually use, not what you might need in a worst-case scenario. Tools like &lt;strong&gt;OpenCost&lt;/strong&gt; and &lt;strong&gt;Kepler&lt;/strong&gt; (a CNCF project) now even provide visibility into Kubernetes spend and carbon consumption per workload.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Ease of Maintenance
&lt;/h3&gt;

&lt;p&gt;Microservices make applications more &lt;strong&gt;portable, testable, and independently deployable&lt;/strong&gt;. Small, well-scoped services are easier to understand, debug, and hand off between teams. This is especially valuable in organizations where multiple product teams need to move at their own pace without stepping on each other.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Secure by Default
&lt;/h3&gt;

&lt;p&gt;Cloud environments are often shared across multiple teams and workloads, requiring layered security models. Cloud native security operates across several dimensions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Network policies&lt;/strong&gt; to restrict traffic between services&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mTLS&lt;/strong&gt; via service meshes to encrypt inter-service communication&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RBAC&lt;/strong&gt; (Role-Based Access Control) to limit what each workload can access&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supply chain security&lt;/strong&gt; using tools like Trivy (image scanning) and KubeSec (Kubernetes manifest analysis)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security zones&lt;/strong&gt; to enforce isolation between tenants or environments&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The CNCF Security Technical Advisory Group continuously develops guidance and tooling to address emerging threats across the cloud native landscape.&lt;/p&gt;




&lt;h2&gt;
  
  
  Observability: The Pillar You Can't Skip
&lt;/h2&gt;

&lt;p&gt;In a distributed, microservices-based architecture, knowing &lt;em&gt;what's happening inside your system&lt;/em&gt; is non-trivial. This is where &lt;strong&gt;observability&lt;/strong&gt; becomes a first-class concern — not something bolted on after problems arise.&lt;/p&gt;

&lt;p&gt;Cloud native observability rests on three pillars:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Metrics&lt;/strong&gt; — quantitative data about system health (CPU, memory, request rates, error rates)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logs&lt;/strong&gt; — structured records of events within services&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traces&lt;/strong&gt; — end-to-end tracking of a request as it flows across multiple microservices&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Key tools in this space:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Prometheus&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Metrics collection and alerting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Grafana&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Visualization dashboards&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Jaeger / Zipkin&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Distributed tracing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OpenTelemetry&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Vendor-neutral instrumentation standard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fluentd / Loki&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Log aggregation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;According to the CNCF Q3 2025 State of Cloud Native report, observability tools are now the &lt;strong&gt;second most widely adopted&lt;/strong&gt; cloud native technology after Kubernetes, used by 28% of backend developers.&lt;/p&gt;




&lt;h2&gt;
  
  
  Immutable Infrastructure: Stop Patching, Start Replacing
&lt;/h2&gt;

&lt;p&gt;Traditional operations involved SSH-ing into servers to apply patches, update configs, or fix issues. Cloud native systems take the opposite approach — &lt;strong&gt;immutable infrastructure&lt;/strong&gt; means servers are never modified after deployment. When a change is needed, a new image is built, tested, and deployed, and the old instance is discarded.&lt;/p&gt;

&lt;p&gt;This makes deployments &lt;strong&gt;predictable and repeatable&lt;/strong&gt;, eliminates configuration drift (where production slowly diverges from your declared desired state), and simplifies rollback — you simply redeploy the previous image.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cloud Native vs. Cloud-Based: Know the Difference
&lt;/h2&gt;

&lt;p&gt;These terms are often used interchangeably, but they describe very different things:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Cloud-Based&lt;/th&gt;
&lt;th&gt;Cloud Native&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Design&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Legacy apps migrated to cloud&lt;/td&gt;
&lt;td&gt;Built from the ground up for cloud&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scaling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Often manual or vertical&lt;/td&gt;
&lt;td&gt;Automatic, horizontal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Deployment&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;May still be monolithic&lt;/td&gt;
&lt;td&gt;Microservices, containers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Infrastructure&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Often mutable&lt;/td&gt;
&lt;td&gt;Immutable, declarative&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Resilience&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Basic backup/restore&lt;/td&gt;
&lt;td&gt;Self-healing, auto-failover&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What's Next: Cloud Native in the Age of AI
&lt;/h2&gt;

&lt;p&gt;Cloud native is rapidly becoming the default platform for AI/ML workloads too. Kubernetes is emerging as the primary runtime for GPU-accelerated training and inference. Tools like &lt;strong&gt;Kubeflow&lt;/strong&gt;, &lt;strong&gt;Argo Workflows&lt;/strong&gt;, and &lt;strong&gt;Kserve&lt;/strong&gt; bring MLOps discipline to model pipelines. The same principles — declarative configuration, autoscaling, observability, and GitOps — apply directly to AI infrastructure.&lt;/p&gt;

&lt;p&gt;This convergence of cloud native and AI is reshaping how organizations think about infrastructure at scale.&lt;/p&gt;




&lt;h2&gt;
  
  
  Getting Started: A Practical Path
&lt;/h2&gt;

&lt;p&gt;If you're new to cloud native, here's a pragmatic starting point:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Containerize a small, stateless service&lt;/strong&gt; with Docker&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy it on Kubernetes&lt;/strong&gt; (try Minikube or kind locally)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set up a basic CI/CD pipeline&lt;/strong&gt; with GitHub Actions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add observability&lt;/strong&gt; with Prometheus and Grafana&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explore GitOps&lt;/strong&gt; with ArgoCD or Flux&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Experiment with a service mesh&lt;/strong&gt; (Istio or Linkerd) as you scale out&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;a href="https://maturitymodel.cncf.io" rel="noopener noreferrer"&gt;CNCF Cloud Native Maturity Model&lt;/a&gt; is an excellent framework for understanding where you are and where to go next.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Cloud native architecture is not a single technology — it's a philosophy and a set of practices for building software that is resilient, scalable, and efficient by design. It demands a shift in how teams think about infrastructure, deployment, and failure. The payoff: faster delivery, lower costs, and systems that can grow as your users do.&lt;/p&gt;

&lt;p&gt;The ecosystem is maturing rapidly, and now is an excellent time to build these skills — they're increasingly foundational to modern software engineering.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Learnings from the &lt;a href="https://www.linuxfoundation.org" rel="noopener noreferrer"&gt;Linux Foundation&lt;/a&gt;'s cloud native curriculum. The Linux Foundation is the parent organization of CNCF and one of the leading sources of open source and cloud native education globally.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudnative</category>
      <category>devops</category>
      <category>kubernetes</category>
      <category>microservices</category>
    </item>
    <item>
      <title>I Just Built an Agentic RAG System From Scratch — Here's What I Learned (LLM Zoomcamp 2026, Module 1)</title>
      <dc:creator>Ryan Giggs</dc:creator>
      <pubDate>Sun, 21 Jun 2026 12:17:44 +0000</pubDate>
      <link>https://dev.to/derrickryangiggs/i-just-built-an-agentic-rag-system-from-scratch-heres-what-i-learned-llm-zoomcamp-2026-module-5a2c</link>
      <guid>https://dev.to/derrickryangiggs/i-just-built-an-agentic-rag-system-from-scratch-heres-what-i-learned-llm-zoomcamp-2026-module-5a2c</guid>
      <description>&lt;p&gt;I just completed Module 1 of the &lt;strong&gt;LLM Zoomcamp 2026&lt;/strong&gt; by &lt;a href="https://github.com/DataTalksClub/llm-zoomcamp/" rel="noopener noreferrer"&gt;@DataTalksClub&lt;/a&gt; — and honestly, this is the most hands-on AI course I've taken.&lt;/p&gt;

&lt;p&gt;No fluff. No hand-holding. Just real code, real concepts, and a working system by the end.&lt;/p&gt;

&lt;p&gt;Here's everything I learned — and why it matters if you're a data engineer or software developer trying to break into LLM applications.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Is LLM Zoomcamp?
&lt;/h2&gt;

&lt;p&gt;LLM Zoomcamp is a &lt;strong&gt;free, open-source course&lt;/strong&gt; by Alexey Grigorev and DataTalks.Club that teaches you how to build production-ready LLM applications from scratch. No GPU required. No expensive API bills. Just Python, curiosity, and a willingness to build.&lt;/p&gt;

&lt;p&gt;Module 1 is called &lt;strong&gt;Agentic RAG&lt;/strong&gt; — and it covers everything from what an LLM is to building a fully autonomous AI agent that decides when and what to search.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Built in Module 1
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. A RAG Pipeline From Scratch
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;RAG&lt;/strong&gt; stands for Retrieval-Augmented Generation. The idea is simple: instead of asking an LLM a question and hoping it knows the answer, you first &lt;em&gt;search&lt;/em&gt; a knowledge base for relevant documents, then pass those documents as context to the LLM.&lt;/p&gt;

&lt;p&gt;The result? Grounded, accurate answers instead of hallucinations.&lt;/p&gt;

&lt;p&gt;I built the full pipeline in ~30 lines of Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;search_results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# 1. find relevant docs
&lt;/span&gt;    &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;search_results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 2. build context prompt
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                         &lt;span class="c1"&gt;# 3. generate answer
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple. Powerful. The foundation of every production RAG system.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Document Indexing with minsearch
&lt;/h3&gt;

&lt;p&gt;I indexed &lt;strong&gt;1,242 FAQ documents&lt;/strong&gt; from the DataTalks.Club Zoomcamp courses using &lt;code&gt;minsearch&lt;/code&gt; — a lightweight keyword search library built by Alexey himself. It uses the same concepts as Elasticsearch (text fields, keyword fields, boosting, filtering) but runs in pure Python with zero infrastructure.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Document Chunking for Better Retrieval
&lt;/h3&gt;

&lt;p&gt;Long documents hurt retrieval precision. A match deep inside a 10,000-character page still pulls the whole page into the LLM context — wasteful and noisy.&lt;/p&gt;

&lt;p&gt;The fix is &lt;strong&gt;chunking&lt;/strong&gt;: split each document into smaller overlapping pieces and index those instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;gitsource&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;chunk_documents&lt;/span&gt;
&lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;chunk_documents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result? &lt;strong&gt;~3× fewer input tokens&lt;/strong&gt; sent to the LLM — smaller, faster, cheaper, and more accurate.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Turning RAG Into an Agent with Function Calling
&lt;/h3&gt;

&lt;p&gt;This is where it gets exciting.&lt;/p&gt;

&lt;p&gt;A standard RAG pipeline is &lt;strong&gt;fixed&lt;/strong&gt;: question → search once → answer. The developer controls the flow.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;agentic RAG&lt;/strong&gt; system puts the LLM in charge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;question → LLM thinks → search? → LLM thinks → search again? → LLM thinks → answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LLM decides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Whether to search at all&lt;/li&gt;
&lt;li&gt;What to search for&lt;/li&gt;
&lt;li&gt;How many times to search&lt;/li&gt;
&lt;li&gt;When it has enough context to answer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I implemented this using &lt;strong&gt;function calling&lt;/strong&gt; — giving the LLM a &lt;code&gt;search&lt;/code&gt; tool it can invoke on its own. In one test, the agent autonomously made &lt;strong&gt;3 different searches&lt;/strong&gt; with progressively refined queries before generating a final answer. No hardcoding. No fixed flow.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LLM&lt;/strong&gt;: Groq API (llama-3.1-8b-instant) — free tier, blazing fast&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search&lt;/strong&gt;: minsearch — lightweight keyword search&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chunking&lt;/strong&gt;: gitsource chunk_documents&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment&lt;/strong&gt;: uv + Python 3.12&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Knowledge base&lt;/strong&gt;: DataTalks.Club Zoomcamp lesson pages (72 markdown files, 295 chunks)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. RAG is just 3 functions.&lt;/strong&gt; search() + build_prompt() + llm(). Everything else is optimization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Chunking matters more than you think.&lt;/strong&gt; Going from full documents to 2,000-character chunks reduced input tokens by 3× and improved answer quality significantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Agents are just loops.&lt;/strong&gt; The "magic" of agentic AI is literally a while loop that keeps calling the LLM until finish_reason == "stop". Understanding this demystifies 90% of agent frameworks like LangChain and LlamaIndex.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. You don't need OpenAI.&lt;/strong&gt; I ran everything on Groq's free tier using Llama 3.1. The OpenAI-compatible API means zero code changes.&lt;/p&gt;




&lt;h2&gt;
  
  
  My Homework Solution
&lt;/h2&gt;

&lt;p&gt;All my code for Module 1 is open source on GitHub:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/Derrick-Ryan-Giggs/llm-zoomcamp-2026" rel="noopener noreferrer"&gt;github.com/Derrick-Ryan-Giggs/llm-zoomcamp-2026&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;rag-intro.ipynb&lt;/code&gt; — the full RAG pipeline&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;agents.ipynb&lt;/code&gt; — function calling and the agentic loop&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;homework.ipynb&lt;/code&gt; — Module 1 homework solutions&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Want to Learn Too?
&lt;/h2&gt;

&lt;p&gt;LLM Zoomcamp is &lt;strong&gt;completely free&lt;/strong&gt;. No paywall, no certificate fees, just open-source learning.&lt;/p&gt;

&lt;p&gt;Sign up here: &lt;a href="https://github.com/DataTalksClub/llm-zoomcamp/" rel="noopener noreferrer"&gt;github.com/DataTalksClub/llm-zoomcamp&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Module 2 is up next — &lt;strong&gt;Vector Search&lt;/strong&gt;. I'll be writing about that too.&lt;/p&gt;

&lt;p&gt;If you're a data engineer, ML practitioner, or software developer who wants to build real LLM applications — not just call ChatGPT — this course is for you.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Are you taking LLM Zoomcamp 2026? Drop a comment — I'd love to connect and compare notes.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>rag</category>
      <category>datatalksclub</category>
    </item>
    <item>
      <title>I Built a Data Lakehouse to Map Kenya's Healthcare Inequality — Here's What I Learned</title>
      <dc:creator>Ryan Giggs</dc:creator>
      <pubDate>Mon, 08 Jun 2026 17:36:51 +0000</pubDate>
      <link>https://dev.to/derrickryangiggs/i-built-a-data-lakehouse-to-map-kenyas-healthcare-inequality-heres-what-i-learned-31b</link>
      <guid>https://dev.to/derrickryangiggs/i-built-a-data-lakehouse-to-map-kenyas-healthcare-inequality-heres-what-i-learned-31b</guid>
      <description>&lt;p&gt;A child born in Westlands, Nairobi has access to 7.23 health facilities per 10,000 people.&lt;/p&gt;

&lt;p&gt;A child born in Embakasi North, also in Nairobi, has 0.62.&lt;/p&gt;

&lt;p&gt;Same city. Same county. One-tenth the access.&lt;/p&gt;

&lt;p&gt;That number did not come from a government report. It did not come from a consultant's PowerPoint. It came from a pipeline I built from scratch, joining three public datasets that nobody had systematically connected before — the Ministry of Health facility registry, the KNBS 2019 Census, and HDX county boundary GeoJSON.&lt;/p&gt;

&lt;p&gt;This is the story of how I built it, why I made the architecture choices I made, and what I would do differently.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Kenya has 47 counties and over 20,000 registered health facilities. The Ministry of Health publishes facility data through the Kenya Master Health Facility Registry (KMHFR). The Kenya National Bureau of Statistics publishes county population data. The Humanitarian Data Exchange publishes county boundary GeoJSON for choropleth mapping.&lt;/p&gt;

&lt;p&gt;Three datasets. Three different sources. All public. None of them joined.&lt;/p&gt;

&lt;p&gt;Without joining them, you cannot answer the most basic questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Which counties are most underserved relative to their population?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Which counties have no maternity facilities? No ART centres? No TB clinics?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;How many facilities would Bungoma need to reach the national baseline of 3 per 10,000 people?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Has the facility landscape changed over time — are counties gaining or losing services?&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The data to answer all of this exists. It just has not been assembled. That is the problem this pipeline solves.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;p&gt;Here is what I built with, and more importantly, why:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Infrastructure as Code: OpenTofu&lt;/strong&gt; — Provisions two MinIO buckets before the stack starts. OpenTofu is the community-maintained open-source fork of Terraform following HashiCorp's BSL license change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Orchestration: Apache Airflow (CeleryExecutor + Redis)&lt;/strong&gt; — Monthly DAG: three ingestion tasks run in parallel, a barrier gate waits for all three to complete, then dbt runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data Lake: MinIO&lt;/strong&gt; — S3-compatible object storage running in Docker. Raw NDJSON files land here first, partitioned by year/month/day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Table Format: Apache Iceberg&lt;/strong&gt; — ACID transactions, schema evolution, and time travel on top of files. The SCD2 snapshot tracks which facilities gained or lost maternity/ART/TB services month over month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catalog: Iceberg REST Catalog&lt;/strong&gt; — SQLite-backed, persists across Docker restarts via a named volume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query Engine: Trino 480&lt;/strong&gt; — Federated SQL over Iceberg tables stored in MinIO.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transformations: dbt Core + dbt-trino&lt;/strong&gt; — Staging models, four mart models, one SCD2 snapshot, 30 passing data quality tests, one seed file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dashboards: Apache Superset 5.0.0&lt;/strong&gt; — Eight charts on one dashboard.&lt;/p&gt;

&lt;p&gt;Everything runs in Docker Compose on my HP EliteBook 840. Zero cloud spend.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture Tradeoff I Need To Be Honest About
&lt;/h2&gt;

&lt;p&gt;Here is where I am going to say something that might surprise you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This stack is overkill for 20,000 rows of data.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Trino is a distributed query engine built for petabyte-scale federated queries across dozens of data sources. My biggest table has 20,391 rows. DuckDB — a single embedded analytics database that runs in a Python process — would query that in milliseconds without any infrastructure at all.&lt;/p&gt;

&lt;p&gt;CeleryExecutor with Redis is designed for Airflow deployments with many concurrent tasks across multiple workers. I have three DAGs that run once a month. LocalExecutor handles that just fine with zero additional services.&lt;/p&gt;

&lt;p&gt;The Iceberg REST catalog backed by SQLite is, genuinely, the worst of both worlds at this scale — you get all of Iceberg's operational complexity without most of its benefits.&lt;/p&gt;

&lt;p&gt;So why did I build it this way?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Because this is the architecture I would run at scale — and I needed to prove I can operate it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If this pipeline were ingesting facility data from all 54 African Union member states, with real-time updates from DHIS2, and serving 200 analysts querying simultaneously — you would need exactly this stack. Trino for federated querying. CeleryExecutor so ingestion tasks do not block dashboard queries. Iceberg for time travel and schema evolution as the data model matures.&lt;/p&gt;

&lt;p&gt;The data volume is small. The architecture patterns are production-grade. I built it this way intentionally, because the goal of a portfolio project is not just to answer a data question — it is to demonstrate that you can operate the systems that answer data questions at companies where the stakes are real.&lt;/p&gt;

&lt;p&gt;When I sit in a data engineering interview and someone asks "have you worked with Trino and Iceberg?" the answer is yes, in anger, debugging catalog persistence issues and permission conflicts at 11pm. That is worth more than a hypothetical answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Actually Found
&lt;/h2&gt;

&lt;p&gt;After all of it was running, here is what the data showed:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;National level:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Bungoma is Kenya's most underserved county: 1.88 facilities per 10,000 people, serving 1.67 million people&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Samburu is the only county with a critical TB gap — just 1 TB facility for 310,327 people&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Every county in Kenya has at least one emergency facility — that surprised me&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Mandera has the highest raw ratio (6.26 per 10k) — but that is sparse population density, not good healthcare access. Ratios lie without context.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Nairobi sub-county drill-down:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Starehe (CBD): 7.23 facilities per 10,000 people&lt;/li&gt;
&lt;li&gt;Embakasi North: 0.62 facilities per 10,000 people&lt;/li&gt;
&lt;li&gt;The most underserved sub-county has eleven times fewer facilities per capita than the most served, within the same city&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Dashboard
&lt;/h2&gt;

&lt;p&gt;Eight charts. One story. Here is what each one shows.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Chart 1 — Facilities per 10,000 People by County&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0wp6nhu3iydiu356ngcn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0wp6nhu3iydiu356ngcn.png" alt=" " width="800" height="388"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The primary national scorecard. Every bar is one of Kenya's 47 counties. The spike you see around Lamu reflects its small population relative to its facility count — a reminder that raw ratios must be read alongside absolute population figures. The counties sitting below 2.0 on the left — Trans Nzoia, Kakamega — are large-population counties being severely underserved in absolute terms. The Y axis is facilities per 10,000 people. The X axis is sorted alphabetically; the ranked view lives in the next chart.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Chart 2 — Underserved Counties Ranking&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj1jpl6n5t9xpsxecahtr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj1jpl6n5t9xpsxecahtr.png" alt=" " width="800" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All 47 counties ranked from most to least underserved. The severity score column is highlighted red for scores of 3 (critical) and pink for scores of 2 — Bungoma and Samburu both carry severity score 3. The &lt;code&gt;facilities_needed_to_baseline&lt;/code&gt; column shows how many additional facilities each county would need just to reach a minimum of 3 per 10,000 people. Bungoma needs 21. The &lt;code&gt;tb_gap_flag&lt;/code&gt; column confirms Samburu as the only county in Kenya with a critical TB service gap. This table is the most action-oriented output in the entire pipeline — it is a prioritised to-do list for the Ministry of Health.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Chart 3 — Maternity Coverage by County&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F44v63kix975yls4vv31h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F44v63kix975yls4vv31h.png" alt=" " width="800" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Maternity facilities per 100,000 people across all 47 counties. The spike at Lamu (~52) is the small-population effect again. More meaningful are the counties between 10 and 17 on the left side — these are where maternal healthcare access is most constrained on a population-adjusted basis. Kenya's maternal mortality remains high in underserved counties, and this chart makes that structural gap visible and quantified for the first time in a reproducible pipeline.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Chart 4 — Service Coverage Rates by County&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz2ry240oqw9lfrivolq5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz2ry240oqw9lfrivolq5.png" alt=" " width="800" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Four services compared side by side for every county: TB (blue), maternity (dark red), ART/HIV treatment (orange-red), and emergency care (amber). The tall blue TB bars dominate because TB diagnosis points are integrated into a wider range of facility types nationally. The very short amber emergency bars across most counties are a quiet crisis in themselves — emergency care coverage is thin across the country. This is the most information-dense chart in the dashboard and rewards close examination county by county.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Chart 5 — Nairobi Sub-County Facility Density&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqbp2bhp4syiva1m969fx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqbp2bhp4syiva1m969fx.png" alt=" " width="800" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the chart that stops people. Embakasi North: 0.62 facilities per 10,000 people. Starehe: 7.23. Same city. The bar on the far right (Starehe/Westlands cluster) towers over everything else. Embakasi North, the short bar third from the left, has 291,760 people and only 18 health facilities. This chart is the most visceral data visualisation in the project — it shows that healthcare inequality is not just a rural versus urban story, but an intra-city story playing out at sub-county level inside Nairobi.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Chart 6 — Nairobi Sub-County Service Gaps&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fefjduq1phmqzpwyoze4q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fefjduq1phmqzpwyoze4q.png" alt=" " width="800" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The full service breakdown for all 17 Nairobi sub-counties, sorted by facilities per 10k ascending — most underserved first. The table shows 2024 projected population (2019 census + 2.3% annual KNBS growth rate) alongside raw counts of maternity, ART, and emergency facilities per sub-county. What stands out is that every sub-county shows no_maternity_flag = false and no_emergency_flag = false. Nairobi's inequality is not a service absence crisis — it is a density crisis. Every sub-county has some maternity and emergency facilities. Not nearly enough of them.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Chart 7 — Total Counties Mapped&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxwb8zq322rxc8qxo6xrf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxwb8zq322rxc8qxo6xrf.png" alt=" " width="800" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A single bold number: 47. All of Kenya's counties accounted for. This is not just cosmetic — a custom dbt test (&lt;code&gt;assert_county_count_equals_47&lt;/code&gt;) enforces this in code. If any pipeline run produces fewer than 47 counties due to data quality issues, join failures, or ingestion gaps, the test fails and the pipeline stops before writing bad data to the mart tables.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Chart 8 — Kenya Facility Density Map&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj5p8321jgd3bdzfc9oz7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj5p8321jgd3bdzfc9oz7.png" alt=" " width="800" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A full choropleth of all 47 Kenya counties, colour-coded by facility density. Light yellow is least served (0.9–1.8 per 10k). Dark maroon is most served (4.4–5.3 per 10k). The dense cluster of small coloured counties in the southwest reflects the high-population Rift Valley and central counties. The large pale northern counties — Turkana, Marsabit, Wajir, Mandera — are sparse in both population and facilities. Building this map required significant engineering: Superset's built-in Kenya map only has the pre-2013 provincial boundaries (8 provinces, not 47 counties), so I built a custom virtual dataset wrapping each county's raw GeoJSON geometry as a full Feature object to make deck.gl render it correctly.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Engineering Problems That Nearly Broke Me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The Superset logout loop&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Both Airflow and Superset were logging me out within 20 seconds of login. The root causes turned out to be three separate things simultaneously: multiple Airflow gunicorn workers not sharing session state, a Superset 5.0 regression breaking database session persistence, and Flask generating a random SECRET_KEY on every container restart — invalidating every session cookie on every &lt;code&gt;docker compose restart&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Fix: &lt;code&gt;AIRFLOW__WEBSERVER__WORKERS=1&lt;/code&gt;, a fixed SECRET_KEY in &lt;code&gt;.env&lt;/code&gt; from &lt;code&gt;openssl rand -hex 32&lt;/code&gt;, and a &lt;code&gt;before_request&lt;/code&gt; Flask hook forcing &lt;code&gt;session.permanent = True&lt;/code&gt; on every request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Iceberg catalog disappearing on restart&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every restart wiped all Trino schema and table knowledge. The REST catalog was using in-memory SQLite by default.&lt;/p&gt;

&lt;p&gt;Fix: &lt;code&gt;CATALOG_URI=jdbc:sqlite:/catalog/iceberg_catalog.db&lt;/code&gt; with a named Docker volume. Two config lines. One hour of debugging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The dbt target/ permission conflict&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The VS Code dbt extension creates &lt;code&gt;dbt/target/&lt;/code&gt; with host user ownership (UID 1000). Airflow runs as UID 50000. Every &lt;code&gt;dbt run&lt;/code&gt; failed with Permission denied.&lt;/p&gt;

&lt;p&gt;Fix: declare &lt;code&gt;dbt-target&lt;/code&gt; as a named Docker volume mounted over the bind-mounted &lt;code&gt;./dbt&lt;/code&gt; directory. Named volumes are owned by the Docker daemon, not the host user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The KMHFR API redirect&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Original pipeline hit &lt;code&gt;kmhfl.health.go.ke&lt;/code&gt;. That domain 301-redirects to &lt;code&gt;kmhfr.health.go.ke&lt;/code&gt;, which returned just the URL path as plain text — not JSON. Three weeks of failed ingestion before finding the correct public API endpoint: &lt;code&gt;api.kmhfr.health.go.ke/api/public/facilities/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Superset Country Map with 8 old provinces&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Superset's built-in Kenya GeoJSON has the pre-2013 provincial boundaries — 8 provinces, not 47 counties. No ISO code mapping fixes this. The solution was switching to deck.gl Polygon with a custom SQL virtual dataset wrapping each county's geometry as a GeoJSON Feature object, with explicit CASE mapping for three county name mismatches across datasets.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Would Do Differently
&lt;/h2&gt;

&lt;p&gt;Use DuckDB instead of Trino for a project of this scale. Use LocalExecutor for Airflow. Start with the data model before the infrastructure.&lt;/p&gt;

&lt;p&gt;The data model is what creates value. The infrastructure is just plumbing. I spent more time debugging the plumbing than thinking about what the data should actually say.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The pipeline runs. The dashboards are live. The data tells a real story.&lt;/p&gt;

&lt;p&gt;The next step is getting it to the people who can act on it: Ministry of Health Kenya, UNICEF Kenya, Amref Health Africa, the Council of Governors, Code for Kenya. The data is public. The pipeline is open source. Anyone can run it.&lt;/p&gt;

&lt;p&gt;If you work at any of those organisations and want to talk about this data — reach out.&lt;/p&gt;

&lt;p&gt;Github : &lt;a href="https://github.com/Derrick-Ryan-Giggs/kenya-health-pipeline" rel="noopener noreferrer"&gt;github.com/Derrick-Ryan-Giggs/kenya-health-pipeline&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>opensource</category>
      <category>python</category>
      <category>healthtech</category>
    </item>
    <item>
      <title>I Built a Real-Time Crypto Analytics Pipeline for $0.01/Month — Here's the Full Architecture</title>
      <dc:creator>Ryan Giggs</dc:creator>
      <pubDate>Mon, 27 Apr 2026 08:39:51 +0000</pubDate>
      <link>https://dev.to/derrickryangiggs/i-built-a-real-time-crypto-analytics-pipeline-for-001month-heres-the-full-architecture-pfl</link>
      <guid>https://dev.to/derrickryangiggs/i-built-a-real-time-crypto-analytics-pipeline-for-001month-heres-the-full-architecture-pfl</guid>
      <description>&lt;p&gt;&lt;em&gt;How I combined Apache Flink, Redpanda, Airflow, dbt Cloud, and Grafana to track Bitcoin, Ethereum, Solana, BNB, and Cardano in real time — all running on Google Cloud for less than a cup of coffee per month.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you've been learning data engineering, you've probably built pipelines that move CSV files from A to B. Nothing wrong with that — but real data engineering interviews are starting to ask a different question: &lt;strong&gt;Can you handle data that never stops arriving?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This article walks through CoinPulse, a project I built from scratch to answer that question. It's a hybrid streaming and batch crypto analytics pipeline that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Consumes live trade events from Binance's WebSocket feed at ~1 tick per second per coin&lt;/li&gt;
&lt;li&gt;Processes them with Apache Flink using 1-minute tumbling windows&lt;/li&gt;
&lt;li&gt;Enriches the streaming data daily with CoinGecko market metadata via Airflow&lt;/li&gt;
&lt;li&gt;Transforms everything with dbt Cloud&lt;/li&gt;
&lt;li&gt;Serves a live, auto-refreshing Grafana dashboard — publicly accessible&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total cloud cost: &lt;strong&gt;~$0.01/month.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's how I built it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem I Was Solving
&lt;/h2&gt;

&lt;p&gt;Crypto market data has two distinct tempos:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real-time (milliseconds to seconds):&lt;/strong&gt; Price ticks from exchanges. You need stream processing here — you can't batch-load ticks after the fact and call it "live." The data volume is too high to dump raw into a warehouse row by row.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Daily (slow-moving context):&lt;/strong&gt; Market cap, trading volume, rankings, OHLC candlesticks. These change once a day. You don't need Flink for this — a simple scheduled API call works fine.&lt;/p&gt;

&lt;p&gt;The interesting engineering challenge is: &lt;strong&gt;how do you join these two tempos cheaply and reliably?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The naive answer — stream everything into BigQuery using the Streaming Insert API — costs money at scale. The smarter answer is what I built: stream to GCS first, batch-load to BigQuery for free, and join the two lanes in dbt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

&lt;p&gt;Before diving in, here's the full picture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;STREAMING LANE
Binance WebSocket → Python Producer → Redpanda → PyFlink → GCS → BigQuery
                    (real-time ticks)  (Kafka)  (windows)  (JSONL) (free load)

BATCH LANE  
CoinGecko API → Airflow DAG (7 tasks) → GCS → BigQuery
                (daily @ 06:00 UTC)    (Parquet)  (free load)

TRANSFORMATION LAYER
BigQuery → dbt Cloud → crypto_staging.* → crypto_mart.*
           (daily @ 07:00 UTC)

VISUALIZATION
BigQuery → Grafana Cloud → 6 panels, auto-refresh 30s
           (BigQuery plugin, free)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything runs locally in Docker except the data warehouse (BigQuery) and transformation/visualization layers (dbt Cloud + Grafana Cloud). This is the "local-to-cloud" pattern — keep compute on your machine, use GCP only for storage and analytics.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Streaming Lane: Binance → Redpanda → PyFlink → GCS
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why Binance Instead of CoinCap?
&lt;/h3&gt;

&lt;p&gt;I originally planned to use CoinCap's WebSocket feed. Halfway through the build, I discovered CoinCap now requires an API key even for the free tier. Binance's WebSocket, however, is completely open — no account, no key, no rate limits on the trade stream.&lt;/p&gt;

&lt;p&gt;The URL pattern is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;wss://stream.binance.com:9443/stream?streams=btcusdt@trade/ethusdt@trade/solusdt@trade/bnbusdt@trade/adausdt@trade
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each message looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"stream"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"btcusdt@trade"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"s"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"BTCUSDT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"p"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"76034.12"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"q"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.00150000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"T"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1776875894886&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Python Producer
&lt;/h3&gt;

&lt;p&gt;The producer is a Docker container running &lt;code&gt;websockets&lt;/code&gt; + &lt;code&gt;confluent-kafka&lt;/code&gt;. It connects to Binance, maps Binance pair names to readable coin names (&lt;code&gt;BTCUSDT → bitcoin&lt;/code&gt;), and publishes JSON messages to a Redpanda topic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;symbol&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bitcoin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price_usd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;76034.12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-04-23T18:31:34+00:00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;producer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;produce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;crypto-prices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bitcoin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why Redpanda Instead of Kafka?
&lt;/h3&gt;

&lt;p&gt;Kafka requires Zookeeper. In a local Docker setup, that's three containers just for the message broker (Zookeeper, Kafka broker, Schema Registry if you want it). Redpanda is Kafka-API compatible — meaning PyFlink's Kafka connector works with it unchanged — but runs as a &lt;strong&gt;single binary, no Zookeeper&lt;/strong&gt;. It starts in under 3 seconds and uses ~512MB RAM instead of Kafka's 2-3GB.&lt;/p&gt;

&lt;h3&gt;
  
  
  The PyFlink Job
&lt;/h3&gt;

&lt;p&gt;The Flink job does the heavy lifting. It consumes from Redpanda, applies a &lt;strong&gt;1-minute tumbling event-time window&lt;/strong&gt; per coin symbol, and computes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;avg_price&lt;/code&gt; — average price within the window&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;min_price&lt;/code&gt; / &lt;code&gt;max_price&lt;/code&gt; — price range&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;price_stddev&lt;/code&gt; — standard deviation (volatility proxy)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;open_price&lt;/code&gt; / &lt;code&gt;close_price&lt;/code&gt; — first and last tick&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;record_count&lt;/code&gt; — number of ticks received&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Event-time windows (as opposed to processing-time) are important here. They use the timestamp embedded in the Binance message, so late-arriving messages (within a 10-second watermark) land in the correct window rather than the current one.&lt;/p&gt;

&lt;p&gt;The output of each completed window is a JSONL file written to GCS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gs://coinpulse-data-lake/streaming/2026/04/23/18/stream_bitcoin_20260423_183134.jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A scheduled BigQuery Load Job (free) then loads these files into &lt;code&gt;crypto_raw.stream_prices&lt;/code&gt;, partitioned by HOUR on &lt;code&gt;event_timestamp&lt;/code&gt; and clustered by &lt;code&gt;symbol&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The key insight here:&lt;/strong&gt; I never use BigQuery's Streaming Insert API, which charges ~$0.01 per 200MB. Load Jobs are completely free. The data is 1 minute old in the warehouse instead of 0 seconds old — but for a dashboard refreshing every 30 seconds, nobody notices.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Batch Lane: CoinGecko → Airflow → GCS → BigQuery
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Airflow DAG
&lt;/h3&gt;

&lt;p&gt;The batch pipeline runs as a 7-task Airflow DAG at 06:00 UTC daily with two parallel branches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fetch_coingecko → transform_to_parquet → upload_to_gcs → load_to_bigquery
fetch_ohlc      → upload_ohlc_to_gcs   → load_ohlc_to_bigquery
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Markets branch&lt;/strong&gt; hits CoinGecko's &lt;code&gt;/coins/markets&lt;/code&gt; endpoint for all 5 coins in a single call, getting current price, market cap, 24h volume, price change percentage, rank, and fully diluted valuation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OHLC branch&lt;/strong&gt; hits &lt;code&gt;/coins/{id}/ohlc?days=1&lt;/code&gt; for each coin, getting candlestick arrays &lt;code&gt;[timestamp, open, high, low, close]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Both branches serialize data to &lt;strong&gt;typed Parquet&lt;/strong&gt; (using explicit schema definitions, not autodetect — more on why this matters below) and upload to GCS before loading to BigQuery.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Schema War (And How I Won It)
&lt;/h3&gt;

&lt;p&gt;This took me longer than I'd like to admit. The root cause: PyArrow (used by Pandas &lt;code&gt;.to_parquet()&lt;/code&gt;) infers Python &lt;code&gt;datetime&lt;/code&gt; objects as &lt;code&gt;TIMESTAMP&lt;/code&gt; in Parquet metadata. But when BigQuery's autodetect reads a &lt;code&gt;datetime.isoformat()&lt;/code&gt; string, it creates the column as &lt;code&gt;STRING&lt;/code&gt;. When the same column arrives as a proper &lt;code&gt;datetime&lt;/code&gt; object in the next run, BigQuery rejects the load because &lt;code&gt;TIMESTAMP&lt;/code&gt; ≠ &lt;code&gt;STRING&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The permanent fix: define explicit schemas in the BigQuery Load Job config and force all string columns to &lt;code&gt;str&lt;/code&gt; dtype in Pandas before writing Parquet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;snapshot_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;snapshot_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ingested_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ingested_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;job_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bigquery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LoadJobConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;source_format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bigquery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SourceFormat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PARQUET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MARKETS_SCHEMA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# explicit, no autodetect
&lt;/span&gt;    &lt;span class="n"&gt;write_disposition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bigquery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteDisposition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WRITE_APPEND&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once I stopped fighting autodetect and defined schemas explicitly, the pipeline ran clean for weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Transformation Layer: dbt Cloud
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why dbt Cloud Over dbt Core
&lt;/h3&gt;

&lt;p&gt;I already had Redpanda, Flink, Airflow, and PostgreSQL running locally — approximately 13GB RAM. Adding a dbt Core Docker container would push that higher for no functional gain. dbt Cloud's free developer plan gives a managed scheduler, visual lineage graph, and test runner at zero cost and zero local RAM.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Model Structure
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Staging layer&lt;/strong&gt; (views, zero storage cost):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;stg_stream_prices&lt;/code&gt; — cleans and type-casts streaming data, filters nulls and zero prices&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;stg_coingecko_markets&lt;/code&gt; — adds &lt;code&gt;market_cap_category&lt;/code&gt; (large/mid/small/micro cap) derived column&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;stg_ohlc_candles&lt;/code&gt; — computes &lt;code&gt;candle_range&lt;/code&gt;, &lt;code&gt;candle_change_pct&lt;/code&gt;, and &lt;code&gt;candle_direction&lt;/code&gt; (bullish/bearish)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Mart layer&lt;/strong&gt; (incremental tables, insert_overwrite):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mart_crypto_prices&lt;/code&gt; — joins streaming aggregations with latest daily market metadata&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mart_volatility&lt;/code&gt; — computes hourly &lt;code&gt;composite_volatility_score&lt;/code&gt; combining streaming stddev with OHLC candle metrics&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mart models use &lt;code&gt;insert_overwrite&lt;/code&gt; with hour-level partitioning and an incremental filter that scans only the last 2 hours of streaming data per run. This means dbt never full-scans the streaming table — it touches only the partitions it needs to update.&lt;/p&gt;

&lt;h3&gt;
  
  
  The dbt Deploy Job
&lt;/h3&gt;

&lt;p&gt;A scheduled dbt Cloud production environment job runs daily at &lt;strong&gt;07:00 UTC&lt;/strong&gt; — one hour after Airflow — ensuring mart tables always have fresh data before the dashboard refreshes.&lt;/p&gt;

&lt;p&gt;After some initial failures (a common dbt Fusion syntax issue with &lt;code&gt;accepted_values&lt;/code&gt; requiring &lt;code&gt;arguments:&lt;/code&gt; nesting), the deploy job has run cleanly every day since April 19, 2026. Each run completes in under 45 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Dashboard: Grafana Cloud
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why Grafana Over Looker Studio
&lt;/h3&gt;

&lt;p&gt;Looker Studio is the natural choice for BigQuery — it has a native connector and no setup friction. But it has one fatal flaw for streaming analytics: &lt;strong&gt;it doesn't auto-refresh&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Grafana Cloud's free tier includes the BigQuery plugin (one-click install, no credit card), supports 30-second auto-refresh, has proper time series panels with logarithmic Y-axis (essential when plotting BTC at $76K alongside ADA at $0.25 on the same chart), and produces significantly more polished visualizations.&lt;/p&gt;

&lt;p&gt;My prior projects (Sovereign Debt Observatory, Tech Ecosystem Observatory) both used Looker Studio, so using Grafana here also demonstrates range across tooling.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 6 Dashboard Panels
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Panel 1 — Live Crypto Prices (Streaming)&lt;/strong&gt;&lt;br&gt;
A treemap showing the latest price for each coin from &lt;code&gt;crypto_raw.stream_prices&lt;/code&gt;, colour-coded green/red by price direction. BTC at $76K, ETH at $2.3K, SOL at $86, BNB at $630, ADA at $0.249.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3en0m48tmf3aw9cu1v1o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3en0m48tmf3aw9cu1v1o.png" alt=" " width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Panel 2 — Price Trend Over Time (Streaming)&lt;/strong&gt;&lt;br&gt;
A time series chart with logarithmic Y-axis showing &lt;code&gt;avg_price&lt;/code&gt; per 1-minute window for all coins. The log scale handles the 300,000x price spread between BTC and ADA.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6hrj4gfo8movdpezaim6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6hrj4gfo8movdpezaim6.png" alt=" " width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Panel 3 — Hourly Volatility Score (Streaming)&lt;/strong&gt;&lt;br&gt;
A gradient bar chart (green → red) showing the &lt;code&gt;composite_volatility_score&lt;/code&gt; per coin from &lt;code&gt;mart_volatility&lt;/code&gt;. Cardano leads at 0.566 (most volatile relative to its price), Binance Coin lowest at 0.264.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqxn1hhyvnnfg967copky.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqxn1hhyvnnfg967copky.png" alt=" " width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Panel 4 — Market Cap Rankings (Batch)&lt;/strong&gt;&lt;br&gt;
BTC dominates at $1.57T, ETH at $289B. The scale difference is immediately visible and gives context to the streaming data.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzcyugt3ejy117zwracvj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzcyugt3ejy117zwracvj.png" alt=" " width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Panel 5 — Daily OHLC Candles (Batch)&lt;/strong&gt;&lt;br&gt;
A table panel showing raw candle data from &lt;code&gt;coingecko_ohlc_candles&lt;/code&gt; with &lt;code&gt;candle_direction&lt;/code&gt; colour-coded — bearish rows highlighted in red.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5fuy7m5zxxv2o5g0nny9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5fuy7m5zxxv2o5g0nny9.png" alt=" " width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Panel 6 — 24h Price Change % (Batch)&lt;/strong&gt;&lt;br&gt;
ADA leads at +0.563%, ETH is the only negative at -0.132%.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcypoq4bqotrzp1ffp2y1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcypoq4bqotrzp1ffp2y1.png" alt=" " width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Public URL:&lt;/strong&gt; &lt;a href="https://derrickryangiggs.grafana.net/public-dashboards/81560968e15140f08f65b52d78a4b252" rel="noopener noreferrer"&gt;https://derrickryangiggs.grafana.net/public-dashboards/81560968e15140f08f65b52d78a4b252&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Infrastructure: Terraform for Everything
&lt;/h2&gt;

&lt;p&gt;All GCP resources are defined in Terraform — nothing clicked in the console. Running &lt;code&gt;terraform apply&lt;/code&gt; provisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GCP project with billing account linked&lt;/li&gt;
&lt;li&gt;Required APIs (BigQuery, BigQuery Storage, Cloud Storage, Resource Manager)&lt;/li&gt;
&lt;li&gt;Service account with least-privilege IAM roles (BQ Data Editor, BQ Job User, GCS Object Admin)&lt;/li&gt;
&lt;li&gt;GCS bucket with 90-day lifecycle rule&lt;/li&gt;
&lt;li&gt;BigQuery datasets and tables with explicit schemas, partitioning, and clustering&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Nothing is hardcoded.&lt;/strong&gt; All values flow from &lt;code&gt;.env&lt;/code&gt; via &lt;code&gt;TF_VAR_&lt;/code&gt; prefixed variables. This is non-negotiable — hardcoded project IDs in &lt;code&gt;.tf&lt;/code&gt; files are a code review failure waiting to happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cost Breakdown (For Real)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Details&lt;/th&gt;
&lt;th&gt;Monthly Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Redpanda&lt;/td&gt;
&lt;td&gt;Local Docker&lt;/td&gt;
&lt;td&gt;$0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PyFlink&lt;/td&gt;
&lt;td&gt;Local Docker&lt;/td&gt;
&lt;td&gt;$0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apache Airflow&lt;/td&gt;
&lt;td&gt;Local Docker&lt;/td&gt;
&lt;td&gt;$0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Cloud Storage&lt;/td&gt;
&lt;td&gt;~200MB of Parquet + JSONL&lt;/td&gt;
&lt;td&gt;~$0.01&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BigQuery&lt;/td&gt;
&lt;td&gt;&amp;lt;1GB data, Load Jobs only&lt;/td&gt;
&lt;td&gt;~$0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dbt Cloud&lt;/td&gt;
&lt;td&gt;Developer plan&lt;/td&gt;
&lt;td&gt;$0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grafana Cloud&lt;/td&gt;
&lt;td&gt;Free tier&lt;/td&gt;
&lt;td&gt;$0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$0.01/month&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The $0.01 is literally the GCS storage bill. Everything else is free.&lt;/p&gt;

&lt;p&gt;The three decisions that make this possible:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use GCS + Load Jobs instead of BigQuery Streaming Inserts.&lt;/strong&gt; Streaming Inserts charge per row. Load Jobs are free. The trade-off is 1-minute data freshness instead of real-time — acceptable for a dashboard refreshing every 30 seconds.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Run compute locally.&lt;/strong&gt; Cloud Composer (managed Airflow) starts at $300/month. Dataproc (managed Flink) is similarly expensive. Running on your own machine costs nothing except electricity.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use free tiers everywhere else.&lt;/strong&gt; dbt Cloud developer plan, Grafana Cloud free tier, CoinGecko Demo API free tier, Binance WebSocket (no auth required). Every external service in this pipeline has a genuinely free tier sufficient for this workload.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Hard-Won Lessons
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Flink's &lt;code&gt;WindowFunction.apply()&lt;/code&gt; signature changed in 2.2.0&lt;/strong&gt;&lt;br&gt;
In PyFlink 1.18, &lt;code&gt;apply(self, key, window, inputs, collector)&lt;/code&gt; uses a collector argument. In 2.2.0, the signature is &lt;code&gt;apply(self, key, window, inputs)&lt;/code&gt; and you use &lt;code&gt;yield&lt;/code&gt; instead. This isn't documented prominently. I lost an afternoon to this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Read env vars inside functions, not at module import time&lt;/strong&gt;&lt;br&gt;
My Airflow DAG originally read &lt;code&gt;GCS_BUCKET_NAME = os.environ["GCS_BUCKET_NAME"]&lt;/code&gt; at the module level. When Airflow's scheduler reloads the module after a container restart, &lt;code&gt;GCS_BUCKET_NAME&lt;/code&gt; is empty — the env var isn't available at import time in the scheduler process. Moving all &lt;code&gt;os.environ&lt;/code&gt; reads inside the task functions fixed this permanently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Never use autodetect for BigQuery Load Jobs if you care about schema stability&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;autodetect=True&lt;/code&gt; infers schema from the first file it sees. If that file has a &lt;code&gt;datetime&lt;/code&gt; object, BQ creates a &lt;code&gt;TIMESTAMP&lt;/code&gt; column. If the next file has an &lt;code&gt;.isoformat()&lt;/code&gt; string, BQ rejects it as a &lt;code&gt;STRING&lt;/code&gt; / &lt;code&gt;TIMESTAMP&lt;/code&gt; mismatch. Define explicit schemas in the &lt;code&gt;LoadJobConfig&lt;/code&gt; and cast DataFrame columns explicitly before writing Parquet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The Flink TaskManager won't connect if &lt;code&gt;jobmanager.rpc.address&lt;/code&gt; in &lt;code&gt;config.yaml&lt;/code&gt; doesn't match your Docker container name&lt;/strong&gt;&lt;br&gt;
The baked-in &lt;code&gt;config.yaml&lt;/code&gt; in the PyFlink image has &lt;code&gt;jobmanager.rpc.address: jobmanager&lt;/code&gt;. My container is named &lt;code&gt;coinpulse-flink-jobmanager&lt;/code&gt;. The &lt;code&gt;FLINK_PROPERTIES&lt;/code&gt; env var doesn't always override &lt;code&gt;config.yaml&lt;/code&gt;. The fix: add a &lt;code&gt;sed&lt;/code&gt; command in the Dockerfile to rewrite the address in &lt;code&gt;config.yaml&lt;/code&gt; directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Redpanda's &lt;code&gt;rpk topic create&lt;/code&gt; command arguments must be on one line&lt;/strong&gt;&lt;br&gt;
Multi-line bash in Docker Compose &lt;code&gt;entrypoint&lt;/code&gt; with &lt;code&gt;&amp;gt;&lt;/code&gt; YAML block scalar doesn't handle line continuations the way you'd expect. The &lt;code&gt;--brokers&lt;/code&gt;, &lt;code&gt;--partitions&lt;/code&gt;, and &lt;code&gt;--replicas&lt;/code&gt; flags ended up being interpreted as separate commands. Fix: use &lt;code&gt;["bash", "-c", "single long command string"]&lt;/code&gt; format instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Add Next (The "Circuit Breaker" Enhancements)
&lt;/h2&gt;

&lt;p&gt;After sharing the project, I got feedback suggesting three production-grade improvements. Here's my assessment:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. dbt-expectations data contracts&lt;/strong&gt;&lt;br&gt;
Add guardrails so the pipeline fails loudly if bad data arrives — negative prices, null coin symbols, impossible price spikes. In dbt, this is two lines per test in your schema YAML. The &lt;code&gt;dbt-expectations&lt;/code&gt; package brings &lt;code&gt;expect_column_values_to_be_between&lt;/code&gt; and similar assertions. Highly recommended, zero cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. BigQuery ML ARIMA_PLUS price forecasting&lt;/strong&gt;&lt;br&gt;
Train a time series model directly in BigQuery SQL to forecast the next 7 days of prices per coin. The first 10GB of &lt;code&gt;CREATE MODEL&lt;/code&gt; data processed monthly is free — both projects' datasets are well under that threshold, making this effectively free. It turns the dashboard from "here's what happened" into "here's what might happen."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. GitHub Actions CI/CD for Terraform&lt;/strong&gt;&lt;br&gt;
A &lt;code&gt;terraform plan&lt;/code&gt; on every PR to the repo, ensuring infrastructure changes are reviewed before applying. Standard practice in production environments, takes about 20 lines of GitHub Actions YAML to implement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Full Stack, Summarised
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;IaC&lt;/td&gt;
&lt;td&gt;Terraform&lt;/td&gt;
&lt;td&gt;Reproducible, version-controlled infrastructure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Message Broker&lt;/td&gt;
&lt;td&gt;Redpanda&lt;/td&gt;
&lt;td&gt;Kafka-compatible, no Zookeeper, runs in one container&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stream Processing&lt;/td&gt;
&lt;td&gt;PyFlink 2.2.0&lt;/td&gt;
&lt;td&gt;Stateful windowed aggregations, event-time semantics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Orchestration&lt;/td&gt;
&lt;td&gt;Apache Airflow&lt;/td&gt;
&lt;td&gt;Full end-to-end DAG, retry logic, monitoring&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Object Storage&lt;/td&gt;
&lt;td&gt;GCS&lt;/td&gt;
&lt;td&gt;Staging layer between compute and warehouse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data Warehouse&lt;/td&gt;
&lt;td&gt;BigQuery&lt;/td&gt;
&lt;td&gt;Serverless, free Load Jobs, columnar, partitioned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transformations&lt;/td&gt;
&lt;td&gt;dbt Cloud&lt;/td&gt;
&lt;td&gt;Incremental models, lineage graph, scheduled runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Visualization&lt;/td&gt;
&lt;td&gt;Grafana Cloud&lt;/td&gt;
&lt;td&gt;Auto-refresh, BigQuery plugin free on all tiers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;

&lt;p&gt;The full project is open source:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/Derrick-Ryan-Giggs/coinpulse" rel="noopener noreferrer"&gt;https://github.com/Derrick-Ryan-Giggs/coinpulse&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live Dashboard:&lt;/strong&gt; &lt;a href="https://derrickryangiggs.grafana.net/public-dashboards/81560968e15140f08f65b52d78a4b252" rel="noopener noreferrer"&gt;https://derrickryangiggs.grafana.net/public-dashboards/81560968e15140f08f65b52d78a4b252&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The README has step-by-step reproduction instructions — clone, fill in &lt;code&gt;.env&lt;/code&gt;, run &lt;code&gt;terraform apply&lt;/code&gt;, start the Docker stacks, and you have a running pipeline within about 20 minutes. All you need is a GCP account (free tier works), a CoinGecko Demo API key (free, no card), dbt Cloud account (free developer plan), and Grafana Cloud account (free forever).&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Project Taught Me
&lt;/h2&gt;

&lt;p&gt;Building this reinforced something I suspected but didn't fully appreciate: &lt;strong&gt;the hardest problems in data engineering aren't the algorithms — they're the plumbing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Getting PyFlink to connect to a Redpanda broker with the right container hostname was a day of debugging. Getting the Airflow DAG to read env vars correctly on scheduled runs (not just manual triggers) was another. Getting BigQuery to accept the same schema consistently across runs required understanding how PyArrow serialises Python types to Parquet column metadata.&lt;/p&gt;

&lt;p&gt;None of these problems are glamorous. But solving them is what separates a "pipeline that worked once on my laptop" from a "pipeline that runs reliably every day."&lt;/p&gt;

&lt;p&gt;That's what CoinPulse is — a pipeline that runs reliably every day, for $0.01/month.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you found this useful or have questions about any part of the implementation, feel free to reach out. I write regularly about data engineering, cloud infrastructure, and GCP on &lt;a href="https://medium.com/@derrickryangiggs" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;, &lt;a href="https://dev.to/derrickryangiggs"&gt;Dev.to&lt;/a&gt;, and &lt;a href="https://ryan-giggs.hashnode.dev" rel="noopener noreferrer"&gt;Hashnode&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>dataengineering</category>
      <category>gcp</category>
      <category>kafka</category>
    </item>
    <item>
      <title>Oracle GoldenGate 23ai: Powering Distributed AI with Real-Time Data Replication</title>
      <dc:creator>Ryan Giggs</dc:creator>
      <pubDate>Sat, 18 Apr 2026 11:10:46 +0000</pubDate>
      <link>https://dev.to/derrickryangiggs/oracle-goldengate-23ai-powering-distributed-ai-with-real-time-data-replication-12a1</link>
      <guid>https://dev.to/derrickryangiggs/oracle-goldengate-23ai-powering-distributed-ai-with-real-time-data-replication-12a1</guid>
      <description>&lt;p&gt;The database world has always needed a reliable bridge between operational systems and analytical or AI workloads. For decades, &lt;strong&gt;Oracle GoldenGate&lt;/strong&gt; has served that role — silently moving terabytes of transactional changes across databases, clouds, and continents with minimal latency and near-zero impact on source systems. With the release of &lt;strong&gt;GoldenGate 23ai&lt;/strong&gt; in 2024, that bridge now carries a new payload: &lt;strong&gt;AI vector embeddings&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This post covers GoldenGate's core use cases, the Microservices Architecture that underpins modern deployments, and the powerful new distributed AI capabilities that make GoldenGate 23ai a critical component of enterprise GenAI pipelines.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is Oracle GoldenGate?
&lt;/h2&gt;

&lt;p&gt;At its heart, GoldenGate is a &lt;strong&gt;Change Data Capture (CDC)&lt;/strong&gt; and real-time replication engine. Rather than querying the source database directly — which would impose load — GoldenGate reads the database's &lt;strong&gt;redo/transaction logs&lt;/strong&gt;, captures only the changed deltas (inserts, updates, deletes), and delivers them to one or more target systems with sub-second latency.&lt;/p&gt;

&lt;p&gt;This log-based approach makes GoldenGate extremely low-impact on production systems while supporting highly demanding replication topologies across heterogeneous environments: Oracle to PostgreSQL, Oracle to Kafka, MySQL to BigQuery, and many more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common GoldenGate Use Cases
&lt;/h2&gt;

&lt;p&gt;GoldenGate's flexibility makes it applicable across a broad range of enterprise scenarios:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Multi-Active, High Availability, and Cross-Region Deployments
&lt;/h3&gt;

&lt;p&gt;GoldenGate enables &lt;strong&gt;active-active (bidirectional) replication&lt;/strong&gt;, where multiple database instances in different regions accept both reads and writes simultaneously. Built-in conflict detection and resolution ensures data consistency across sites. This architecture powers mission-critical applications that require zero planned downtime and regional failover capabilities — with GoldenGate's &lt;strong&gt;ExaPortMon&lt;/strong&gt;-style awareness ensuring continuous replication even under network disruptions.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Data Offloading and Data Hub
&lt;/h3&gt;

&lt;p&gt;Rather than running analytical queries against production OLTP databases, GoldenGate streams &lt;strong&gt;only the changed data&lt;/strong&gt; to a dedicated analytics database, data warehouse, or data lake in real time. This eliminates the traditional "nightly CSV batch" pattern and gives analysts access to data that is seconds, not hours, behind production.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Migrations and Upgrades
&lt;/h3&gt;

&lt;p&gt;GoldenGate is the gold standard for &lt;strong&gt;zero-downtime database migrations&lt;/strong&gt; — moving from on-premises to cloud, upgrading Oracle Database versions, or switching from Oracle to PostgreSQL. The approach: synchronize old and new systems in parallel using GoldenGate, then cut over the application connection once replication lag reaches zero. Even migrations of hundreds of terabytes are reduced to a switchover window measured in minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Analytics Data Feeds
&lt;/h3&gt;

&lt;p&gt;GoldenGate feeds real-time change streams into analytics platforms — data warehouses, Apache Kafka topics, Oracle Streaming Service, Confluent Cloud, and cloud object stores. This underpins event-driven architectures and real-time dashboards that would be impossible with batch-based ETL.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Stream Analytics
&lt;/h3&gt;

&lt;p&gt;With &lt;strong&gt;GoldenGate Stream Analytics&lt;/strong&gt;, change events flowing through GoldenGate can be enriched, filtered, and analyzed in motion — before they land in a target system. Integrations with &lt;strong&gt;Oracle Machine Learning (OML)&lt;/strong&gt; and &lt;strong&gt;ONNX (Open Neural Network Exchange)&lt;/strong&gt; enable actionable AI/ML directly from streaming pipelines, turning raw CDC events into predictive signals.&lt;/p&gt;

&lt;h2&gt;
  
  
  GoldenGate Microservices Architecture
&lt;/h2&gt;

&lt;p&gt;When Oracle introduced the &lt;strong&gt;Microservices Architecture (MA)&lt;/strong&gt; in &lt;strong&gt;GoldenGate 12.3 (August 2017)&lt;/strong&gt;, it represented a fundamental modernization of how GoldenGate is deployed, managed, and integrated. The classic command-line-driven architecture (GGSCI) was complemented by a fully API-driven, web-based platform.&lt;/p&gt;

&lt;h3&gt;
  
  
  Five Core Components
&lt;/h3&gt;

&lt;p&gt;The Microservices Architecture is built around five independently manageable services:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Service Manager&lt;/strong&gt; — acts as a watchdog, managing and monitoring all other services within a deployment&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Administration Server&lt;/strong&gt; — the central control entity for managing Extract, Replicat, and all replication processes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distribution Server&lt;/strong&gt; — a high-performance data distribution agent that routes trail files to one or more targets over HTTPS/WebSockets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Receiver Server&lt;/strong&gt; — handles all incoming trail files from remote Distribution Servers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance Metrics Server&lt;/strong&gt; — collects and exposes real-time performance data for all GoldenGate processes via REST API or JSON&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Key Benefits of Microservices Architecture
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;REST API-driven management&lt;/strong&gt;: Every GoldenGate operation — starting a replicat, checking lag, configuring a pipeline — is exposed via a standardized REST API, enabling automation with Python, Terraform, and CI/CD pipelines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Renewed web UI in 23ai&lt;/strong&gt;: GoldenGate 23ai ships with a &lt;strong&gt;completely redesigned graphical interface&lt;/strong&gt;, with more logical, guided assistant steps for setting up integrations — a marked improvement over the previous look and feel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TLS encryption and OAuth 2.0 authentication&lt;/strong&gt; out of the box, with integration into external identity providers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simpler patching and upgrades&lt;/strong&gt;: Each microservice can be updated with minimal disruption. Upgrading from GoldenGate 21c to 23ai, for example, requires only downloading the new software and updating the &lt;code&gt;OGG_HOME&lt;/code&gt; directory parameters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment flexibility&lt;/strong&gt;: Runs on-premises, as a fully managed service in OCI (OCI GoldenGate), or on third-party clouds including AWS, Azure, and GCP.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  GoldenGate Version Upgrade Path
&lt;/h3&gt;

&lt;p&gt;For teams still running older GoldenGate versions, the supported upgrade sequence through the modern microservices era is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;12.1 → 12.2 → 12.3 (Microservices introduced) → 18c → 19c → 21c → 23ai → 26ai (next LTS)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;: Classic Architecture support for Oracle Database is deprecated in 23ai. All new deployments should use Microservices Architecture. GoldenGate &lt;strong&gt;26ai&lt;/strong&gt; has been announced as the next Long-Term Support (LTS) release, with further AI and Data Mesh enhancements planned.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Distributed AI Processing with Vector Replication
&lt;/h2&gt;

&lt;p&gt;This is where GoldenGate 23ai breaks genuinely new ground. The release adds first-class support for the &lt;strong&gt;Oracle Database 23ai &lt;code&gt;VECTOR&lt;/code&gt; data type&lt;/strong&gt; — the foundational building block of AI Vector Search and RAG (Retrieval-Augmented Generation) pipelines.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Is a Vector Embedding?
&lt;/h3&gt;

&lt;p&gt;When AI models process documents, images, or other unstructured data, they convert that content into &lt;strong&gt;vector embeddings&lt;/strong&gt; — high-dimensional numerical representations that encode semantic meaning. These vectors are stored in vector databases and searched using similarity algorithms (cosine distance, dot product, Euclidean distance) to retrieve contextually relevant content.&lt;/p&gt;

&lt;p&gt;Keeping vector stores synchronized with the operational databases that hold the source data is a significant engineering challenge — and exactly what GoldenGate 23ai solves.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key Vector Replication Capabilities
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Migrate Vectors into Oracle Vector Database Without Downtime&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;GoldenGate can migrate vector embeddings stored in one database into Oracle Database 23ai (or Oracle AI Database 26ai) without any service interruption. This enables seamless transitions from on-premises environments to OCI, or between cloud providers, while keeping AI-powered applications running continuously.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Replicate and Consolidate Vector Changes in Real Time&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;As source data changes — a customer record is updated, a product description is revised — GoldenGate captures those changes and propagates updated vectors to all target vector stores in real time. This ensures that RAG pipelines are always querying fresh, consistent embeddings rather than stale snapshots.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Heterogeneous Vector Replication&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;GoldenGate 23ai can replicate vectors both homogeneously (Oracle to Oracle) and heterogeneously across different vector stores, provided the &lt;strong&gt;embedding algorithm, type, and dimension are consistent&lt;/strong&gt; across source and target. If the source and target use different embedding algorithms, GoldenGate can replicate the &lt;strong&gt;raw source data&lt;/strong&gt; instead, allowing the target system to re-vectorize it with its own model.&lt;/p&gt;

&lt;p&gt;Support extends beyond Oracle: GoldenGate 23ai also supports capture and delivery of the &lt;strong&gt;pgvector extension&lt;/strong&gt; for PostgreSQL and its derivatives, as well as the &lt;strong&gt;VECTOR datatype for MySQL 9.0+&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-Cloud, Multi-Active Oracle Vector Database&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;GoldenGate enables &lt;strong&gt;active-active vector database architectures&lt;/strong&gt; spanning multiple cloud regions or providers. AI applications in different regions can write to and read from local vector stores while GoldenGate keeps all instances synchronized — enabling both low-latency AI inference and high availability for vector workloads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stream Changes to Search Engines&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;GoldenGate can stream vector and document changes in real time to &lt;strong&gt;Elasticsearch or OpenSearch compatible search indexes&lt;/strong&gt;, enabling hybrid semantic + keyword search architectures without manual synchronization pipelines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GoldenGate Data Streams: Pub/Sub for Database Events&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A major new capability in GoldenGate 23ai is the &lt;strong&gt;Data Streams service&lt;/strong&gt;, which provides simplified, event-driven access to database change records via &lt;strong&gt;AsyncAPI over WebSocket connections&lt;/strong&gt;. This enables developers to subscribe to database change events — including vector updates — in a pub/sub pattern, without needing to manage trail files or configure traditional Replicat processes. Combined with &lt;strong&gt;Oracle Database 23ai's JSON Relational Duality views&lt;/strong&gt;, this means document-side changes can be captured and propagated as structured events in real time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generative AI with Your Own Business Data
&lt;/h2&gt;

&lt;p&gt;GoldenGate 23ai directly enables three patterns for building AI applications on private enterprise data:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. From-Scratch LLMs (Custom Foundation Models)
&lt;/h3&gt;

&lt;p&gt;Organizations with truly unique proprietary datasets — medical records, legal case files, financial transaction histories — may opt to train custom Large Language Models from the ground up on that data. GoldenGate ensures the training datasets fed into these models are continuously fresh and complete, sourced in real time from operational databases rather than periodic exports.&lt;/p&gt;

&lt;p&gt;This is an advanced, resource-intensive approach suited to well-resourced organizations with data assets that are sufficiently distinctive to justify the investment.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Fine-Tuned LLMs (Domain Adaptation)
&lt;/h3&gt;

&lt;p&gt;A more practical approach for most enterprises: take a pre-trained foundational LLM (such as Llama, Mistral, or an OCI Generative AI model) and &lt;strong&gt;fine-tune it on a private domain dataset&lt;/strong&gt;. GoldenGate replicates the relevant operational data into a training pipeline, keeping the fine-tuned model aligned with current business reality as data evolves over time.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. RAG (Retrieval-Augmented Generation) — The Most Common Pattern
&lt;/h3&gt;

&lt;p&gt;RAG is currently the dominant architecture for enterprise GenAI. Instead of baking private knowledge into model weights (which requires expensive retraining), RAG retrieves relevant documents at query time and injects them into the LLM's prompt as context.&lt;/p&gt;

&lt;p&gt;GoldenGate 23ai is purpose-built to power better RAG pipelines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keeps vector stores current&lt;/strong&gt;: As source data changes, GoldenGate propagates those changes to the vector index within seconds — not hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enables a consolidated vector hub&lt;/strong&gt;: Rather than maintaining separate, potentially inconsistent vector stores for each application, GoldenGate can consolidate vectors from multiple source databases into a single Oracle AI Database vector hub.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reduces hallucination risk&lt;/strong&gt;: Stale vector indexes are one of the primary causes of LLM hallucinations in RAG systems. Fresh data = more accurate, grounded responses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supports heterogeneous sources&lt;/strong&gt;: GoldenGate can pull data from Oracle, PostgreSQL, MySQL, SQL Server, DB2, and more — vectorize it, and deliver it to Oracle AI Database, all in a unified pipeline.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why GoldenGate 23ai for AI: The Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Real-time vector replication&lt;/td&gt;
&lt;td&gt;RAG pipelines query fresh embeddings, not stale snapshots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zero-downtime vector migration&lt;/td&gt;
&lt;td&gt;Move AI workloads to cloud without service interruption&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-cloud active-active vectors&lt;/td&gt;
&lt;td&gt;Low-latency AI inference globally with high availability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heterogeneous source support&lt;/td&gt;
&lt;td&gt;Unify vectors from Oracle, PostgreSQL, MySQL, and more&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data Streams (AsyncAPI)&lt;/td&gt;
&lt;td&gt;Event-driven AI pipelines without complex trail file management&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stream to search engines&lt;/td&gt;
&lt;td&gt;Elasticsearch/OpenSearch integration for hybrid search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fine-tuning data pipelines&lt;/td&gt;
&lt;td&gt;Keep domain-adapted LLMs current with live business data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The underlying thesis is the same one Oracle argues for Exadata AI Storage: &lt;strong&gt;bring AI to the data, not data to the AI&lt;/strong&gt;. GoldenGate 23ai extends this philosophy to the replication layer — ensuring that wherever your AI application runs, it has access to consistent, real-time data from your authoritative operational systems.&lt;/p&gt;

&lt;p&gt;As enterprise AI moves from experimentation to production, the quality of data pipelines will increasingly determine the quality of AI outcomes. GoldenGate 23ai is Oracle's answer to that challenge.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Are you using GoldenGate for AI pipelines or RAG architectures? What challenges have you run into keeping vector stores synchronized? Share in the comments below.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>oraclegoldengate</category>
      <category>dataengineering</category>
      <category>generativeai</category>
      <category>datareplication</category>
    </item>
    <item>
      <title>Building the Sovereign Debt Observatory: An End-to-End ELT Pipeline on World Bank Debt Data for Low and Middle-Income Countries</title>
      <dc:creator>Ryan Giggs</dc:creator>
      <pubDate>Thu, 09 Apr 2026 10:02:52 +0000</pubDate>
      <link>https://dev.to/derrickryangiggs/building-the-sovereign-debt-observatory-an-end-to-end-elt-pipeline-on-world-bank-debt-data-for-low-4988</link>
      <guid>https://dev.to/derrickryangiggs/building-the-sovereign-debt-observatory-an-end-to-end-elt-pipeline-on-world-bank-debt-data-for-low-4988</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Global sovereign debt is one of the most consequential datasets in existence. It shapes foreign policy, determines credit ratings, drives IMF bailout decisions, and affects the daily lives of billions of people in developing countries. The World Bank publishes this data openly — 130+ countries, 27 years of history, updated quarterly — yet there is no ready-made analytical layer on top of it.&lt;/p&gt;

&lt;p&gt;If you want to answer a question like "which African countries have the highest ratio of private nonguaranteed debt to total external debt, and how has that changed since 2010?", you have to manually download Excel files from multiple World Bank portals, clean inconsistent column names, handle missing values, and stitch everything together in a spreadsheet. Every time the data updates, you do it again.&lt;/p&gt;

&lt;p&gt;That is the problem this project solves.&lt;/p&gt;

&lt;p&gt;The Sovereign Debt Observatory is an end-to-end ELT pipeline that ingests World Bank external debt data, lands it in a cloud data lake, transforms it in BigQuery using dbt Cloud, orchestrates everything quarterly with Apache Airflow, and surfaces the answers in a Looker Studio dashboard.&lt;/p&gt;

&lt;p&gt;This article walks through every layer of the pipeline — the architecture decisions, the technical challenges, and how I solved them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Five Questions This Pipeline Answers
&lt;/h2&gt;

&lt;p&gt;Before writing a single line of code, I defined the analytical questions the pipeline needed to answer. This kept every decision grounded in purpose rather than technology for its own sake.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;How is gross external debt distributed across public, publicly guaranteed, private nonguaranteed, and multilateral sectors per country?&lt;/li&gt;
&lt;li&gt;Which countries carry the highest short-term external debt exposure and how has that changed since 2010?&lt;/li&gt;
&lt;li&gt;What share of external debt is foreign-currency denominated and where is that ratio worsening?&lt;/li&gt;
&lt;li&gt;How has regional external debt stock evolved from 1998 to 2025 across Africa, Latin America, East Asia, South Asia, Europe and Central Asia, and the Middle East?&lt;/li&gt;
&lt;li&gt;Which countries face the heaviest debt service pressure relative to their total debt position?&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

&lt;p&gt;The pipeline follows a modern ELT pattern — extract and load first, transform inside the warehouse.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;World Bank API (IDS source 2)
        |
        v
PySpark job (Docker container)
        |
        v
Google Cloud Storage — raw Parquet, partitioned by extracted_date
        |
        v
BigQuery external tables (raw dataset)
        |
        v
dbt Cloud — staging views + mart tables
        |
        v
Looker Studio dashboard
        |
Orchestrated by Apache Airflow on Docker Compose
Infrastructure provisioned by Terraform
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why ELT and not ETL?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In ETL, transformation happens before loading — your Spark job does the cleaning, aggregation, and business logic before writing to the warehouse. In ELT, raw data lands untransformed and the warehouse does all the heavy lifting.&lt;/p&gt;

&lt;p&gt;For this project, ELT is the right choice for three reasons. First, raw data is preserved in GCS indefinitely — if analytical requirements change six months from now, I just write a new dbt model without re-running the ingestion layer. Second, BigQuery is optimized for analytical SQL transformations at scale — it is far better at this than PySpark running in a Docker container on a local machine. Third, dbt gives us version-controlled, tested, documented transformations that are readable by anyone with SQL knowledge.&lt;/p&gt;

&lt;p&gt;PySpark's job in this pipeline is purely mechanical: hit the API, paginate, write Parquet. No business logic. No aggregations. Pure extract and load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data Sources
&lt;/h2&gt;

&lt;h3&gt;
  
  
  International Debt Statistics (IDS) — World Bank source 2
&lt;/h3&gt;

&lt;p&gt;The IDS database is the flagship World Bank debt dataset. It covers external debt stocks and flows for low and middle income countries, with annual data going back to 1998. I access it through the &lt;code&gt;wbgapi&lt;/code&gt; Python library, which wraps the World Bank Indicators API v2.&lt;/p&gt;

&lt;p&gt;I ingest nine series:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Series Code&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DT.DOD.DECT.CD&lt;/td&gt;
&lt;td&gt;Total external debt stocks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DT.DOD.DLXF.CD&lt;/td&gt;
&lt;td&gt;Long-term external debt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DT.DOD.DPNG.CD&lt;/td&gt;
&lt;td&gt;Private nonguaranteed debt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DT.DOD.MIBR.CD&lt;/td&gt;
&lt;td&gt;PPG IBRD loans&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DT.DOD.DPPG.CD&lt;/td&gt;
&lt;td&gt;Public and publicly guaranteed debt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DT.DOD.DIMF.CD&lt;/td&gt;
&lt;td&gt;IMF credit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DT.DOD.PVLX.CD&lt;/td&gt;
&lt;td&gt;Present value of external debt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DT.DOD.MWBG.CD&lt;/td&gt;
&lt;td&gt;IBRD loans and IDA credits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DT.DOD.MIDA.CD&lt;/td&gt;
&lt;td&gt;PPG IDA loans&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One important lesson from building this: the World Bank has multiple API source databases, and not all of them support the standard &lt;code&gt;wbgapi&lt;/code&gt; query format. Sources 22 (QEDS SDDS), 23 (QEDS GDDS), and 54 (JEDH) all return JSON decode errors when queried programmatically — they use a separate DataBank backend. Only source 2 (IDS) reliably supports the Indicators API. This cost me several hours of debugging.&lt;/p&gt;

&lt;h3&gt;
  
  
  Quarterly External Debt Statistics SDDS (QEDS)
&lt;/h3&gt;

&lt;p&gt;QEDS provides quarterly debt payment schedule data broken down by sector and maturity. Unlike IDS, QEDS does not support programmatic API access in the standard format. The World Bank provides bulk Excel downloads for each supplementary table instead.&lt;/p&gt;

&lt;p&gt;I download five Excel files directly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Table 1.5 — Net external debt position by sector&lt;/li&gt;
&lt;li&gt;Table 3 — Debt service payment schedule by sector&lt;/li&gt;
&lt;li&gt;Table 3.2 — Debt service by sector and instrument&lt;/li&gt;
&lt;li&gt;Table 2.1 — Foreign currency and domestic currency debt&lt;/li&gt;
&lt;li&gt;Table 1.6 — Reconciliation of positions and flows&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Infrastructure as Code with Terraform
&lt;/h2&gt;

&lt;p&gt;Every GCP resource in this project is provisioned by Terraform. The three core resources are a GCS bucket for the data lake and three BigQuery datasets — raw, staging, and mart.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"google_storage_bucket"&lt;/span&gt; &lt;span class="s2"&gt;"data_lake"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gcs_bucket_name&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;
  &lt;span class="nx"&gt;force_destroy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="nx"&gt;lifecycle_rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Delete"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;age&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;versioning&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"google_bigquery_dataset"&lt;/span&gt; &lt;span class="s2"&gt;"raw"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;dataset_id&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"raw"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;
  &lt;span class="nx"&gt;delete_contents_on_destroy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 90-day lifecycle rule on GCS automatically deletes old partitions, keeping storage costs near zero. The entire GCP footprint for this project costs less than $0.05 per month — BigQuery's free tier covers 1 TB of queries and 10 GB of storage, which is far more than this dataset requires.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;setup_gcp.sh&lt;/code&gt; script handles the one-time bootstrap — creating the GCP project, enabling APIs, creating the service account, and granting IAM roles. The billing account ID is passed as an environment variable so it never appears in version-controlled files.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;BILLING_ACCOUNT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-billing-id bash scripts/setup_gcp.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Ingestion Layer — PySpark on Docker
&lt;/h2&gt;

&lt;h3&gt;
  
  
  JEDH / IDS Ingestion
&lt;/h3&gt;

&lt;p&gt;The IDS ingestion script uses &lt;code&gt;wbgapi&lt;/code&gt; to fetch all nine series across all available countries from 1998 to 2025. The API returns data in wide format — one row per country per series, with year columns as separate fields. PySpark writes this to GCS as Snappy-compressed Parquet, partitioned by extraction date.&lt;/p&gt;

&lt;p&gt;One critical issue I hit: the World Bank API returns year columns as bare integers (&lt;code&gt;1998&lt;/code&gt;, &lt;code&gt;1999&lt;/code&gt;, etc.). BigQuery rejects column names that start with numbers. The fix was to prefix all year columns with &lt;code&gt;year_&lt;/code&gt; before writing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;combined&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;year_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isdigit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;combined&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This produces clean column names like &lt;code&gt;year_1998&lt;/code&gt;, &lt;code&gt;year_1999&lt;/code&gt; that BigQuery accepts without complaint.&lt;/p&gt;

&lt;h3&gt;
  
  
  QEDS Ingestion
&lt;/h3&gt;

&lt;p&gt;The QEDS ingestion downloads five Excel files from World Bank DataBank, reads all sheets from each file using &lt;code&gt;pandas.read_excel&lt;/code&gt;, and concatenates them into a single DataFrame.&lt;/p&gt;

&lt;p&gt;The Excel files have messy column names — spaces, brackets, special characters. A &lt;code&gt;clean_column_name&lt;/code&gt; function normalizes everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;clean_column_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;\s*\[.*?\]&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# remove [YR2021Q4] suffixes
&lt;/span&gt;    &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;[^a-zA-Z0-9_]&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;_+&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;isdigit&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;q_&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;  &lt;span class="c1"&gt;# prefix quarter columns: q_2021q4
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also hit an OOM error trying to write the QEDS data through PySpark — 76,835 rows with 90+ columns across 214 sheets was too much for the JVM heap in the Docker container. The fix was to bypass Spark entirely for QEDS and write directly to GCS using the &lt;code&gt;google-cloud-storage&lt;/code&gt; Python client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nb"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_parquet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pyarrow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seek&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GCS_BUCKET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upload_from_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/octet-stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No JVM, no Spark executor, no OOM. The lesson: use the right tool for the job. PySpark is excellent for large distributed datasets. For a 76K-row DataFrame from an Excel file, plain pandas and the GCS Python client is simpler and more reliable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker Image
&lt;/h3&gt;

&lt;p&gt;The ingestion image is built on &lt;code&gt;eclipse-temurin:17-jdk-jammy&lt;/code&gt; rather than a plain Python image. This is because PySpark requires Java, and the &lt;code&gt;python:3.11-slim&lt;/code&gt; base image uses Debian Trixie which does not carry &lt;code&gt;openjdk-17-jdk&lt;/code&gt; in its default repositories. The Temurin image ships Java 17 out of the box, which is exactly what Spark 3.5.1 needs.&lt;/p&gt;

&lt;p&gt;The GCS connector JAR is downloaded at build time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;curl &lt;span class="nt"&gt;--progress-bar&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    https://storage.googleapis.com/hadoop-lib/gcs/gcs-connector-hadoop3-latest.jar &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SPARK_HOME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;/jars/gcs-connector-hadoop3-latest.jar
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Credentials are never baked into the image. They are mounted at runtime as a volume:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; /path/to/key.json:/app/credentials/key.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;GOOGLE_APPLICATION_CREDENTIALS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/app/credentials/key.json &lt;span class="se"&gt;\&lt;/span&gt;
  sovereign-debt-ingestion:v1 python3 extract_jedh.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Orchestration with Apache Airflow
&lt;/h2&gt;

&lt;p&gt;Airflow runs on Docker Compose using the official &lt;code&gt;apache/airflow:2.9.2&lt;/code&gt; image. The stack includes a Celery executor with Redis as the message broker and Postgres as the metadata database.&lt;/p&gt;

&lt;p&gt;The DAG runs quarterly — on the 1st of January, April, July, and October at 06:00 UTC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;schedule_interval&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0 6 1 1,4,7,10 *&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Task flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;extract_load_jedh&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;extract_load_qeds&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both tasks use the &lt;code&gt;DockerOperator&lt;/code&gt; to spin up the ingestion container on the host machine, which means Airflow itself does not need PySpark, Java, or any data dependencies — it just tells Docker to run the job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Docker socket problem&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;DockerOperator&lt;/code&gt; communicates with the host Docker daemon through the Unix socket at &lt;code&gt;/var/run/docker.sock&lt;/code&gt;. This requires two things: the socket must be mounted into the Airflow worker container, and the worker must have permission to use it.&lt;/p&gt;

&lt;p&gt;The socket mount goes in &lt;code&gt;docker-compose.yml&lt;/code&gt; under the common volumes section:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The permission fix requires adding the Docker group ID to the Airflow worker. On my machine the Docker group ID is 984:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;group_add&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;984"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the group add, the worker can see the socket but gets &lt;code&gt;PermissionError(13, 'Permission denied')&lt;/code&gt;. This is a common Airflow + Docker-in-Docker gotcha that took several debugging sessions to resolve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Transformation with dbt Cloud
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Staging Layer
&lt;/h3&gt;

&lt;p&gt;The staging models are materialized as views — no storage cost, no latency, just a SQL lens over the raw external tables.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;stg_jedh&lt;/code&gt; does the heavy lifting: it unpivots the wide year-column format into long format using BigQuery's &lt;code&gt;UNPIVOT&lt;/code&gt; operator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;unpivot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="nb"&gt;year&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;year_1998&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;year_1999&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;year_2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...,&lt;/span&gt; &lt;span class="n"&gt;year_2025&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then extracts the year integer from the column name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;cast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'year_'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;year&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And adds a human-readable series description via a CASE statement, turning &lt;code&gt;DT.DOD.DECT.CD&lt;/code&gt; into &lt;code&gt;Total external debt stocks&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The result is a clean long-format table: one row per country per series per year.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;stg_qeds&lt;/code&gt; is simpler — it selects the clean columns, uses &lt;code&gt;SAFE_CAST&lt;/code&gt; to convert quarter values to float64, and filters out null countries and series codes. &lt;code&gt;SAFE_CAST&lt;/code&gt; is preferable to &lt;code&gt;CAST&lt;/code&gt; here because it returns NULL on failure rather than throwing an error, which is the right behaviour for messy Excel data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mart Layer
&lt;/h3&gt;

&lt;p&gt;The mart models are materialized as tables with partitioning and clustering for query efficiency.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mart_debt_stocks&lt;/code&gt; is partitioned by year and clustered by &lt;code&gt;country_code&lt;/code&gt; and &lt;code&gt;series_code&lt;/code&gt;. It enriches the staging data with YoY percentage change and debt as a percentage of total external debt per country per year — both computed using window functions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;debt_value_usd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;over&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;partition&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;country_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;series_code&lt;/span&gt;
    &lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;year&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;prev_year_value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

&lt;span class="n"&gt;safe_divide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;debt_value_usd&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;debt_value_usd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;over&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;partition&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;country_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;series_code&lt;/span&gt;
        &lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;year&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;debt_value_usd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;over&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;partition&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;country_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;series_code&lt;/span&gt;
        &lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;year&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;yoy_change_pct&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mart_regional_debt&lt;/code&gt; assigns countries to six World Bank regions using a CASE statement on ISO3 country codes, then aggregates total, average, max, and min debt stocks per region per series per year. This powers the regional trajectory time series on the dashboard.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mart_debt_service&lt;/code&gt; computes total annual debt payments and average quarterly payments per country from the QEDS payment schedule data, enabling debt service pressure analysis.&lt;/p&gt;

&lt;h3&gt;
  
  
  dbt Tests
&lt;/h3&gt;

&lt;p&gt;All models have &lt;code&gt;not_null&lt;/code&gt; tests on primary dimension columns. Running &lt;code&gt;dbt test&lt;/code&gt; after every model change ensures data quality is enforced at the transformation layer rather than discovered downstream in the dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Dashboard
&lt;/h2&gt;

&lt;p&gt;The Looker Studio dashboard has two pages, both connected directly to the BigQuery mart tables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Page 1 — Global Debt Overview&lt;/strong&gt; answers Q4 at a glance. A time series chart using &lt;code&gt;mart_regional_debt&lt;/code&gt; shows six regional debt trajectories from 1998 to 2024. Africa's trajectory is notably steeper post-2010. A bar chart shows the top 20 countries by total external debt for the selected year. A scorecard shows total global external debt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Page 2 — Country Deep-Dive&lt;/strong&gt; answers Q1, Q2, and Q5. A stacked bar chart shows debt composition by sector over time for any selected country. A line chart shows short-term vulnerability trends. A table sorted by total 2021 payments shows which countries face the most acute debt service pressure.&lt;/p&gt;

&lt;p&gt;Live dashboard: &lt;a href="https://lookerstudio.google.com/reporting/7fc18e9e-a5c6-4616-b920-b5b4bddf2264" rel="noopener noreferrer"&gt;https://lookerstudio.google.com/reporting/7fc18e9e-a5c6-4616-b920-b5b4bddf2264&lt;/a&gt; &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcndjtztlmuh5xrijntk0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcndjtztlmuh5xrijntk0.png" alt=" " width="800" height="220"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft3tp1p9cm44v81acuofi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft3tp1p9cm44v81acuofi.png" alt=" " width="800" height="325"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4e46s1e6kfyq7mzy12h5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4e46s1e6kfyq7mzy12h5.png" alt=" " width="800" height="349"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frxg2xfudqw09l22wczwr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frxg2xfudqw09l22wczwr.png" alt=" " width="800" height="253"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Technical Lessons
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Know which World Bank API sources support programmatic access.&lt;/strong&gt; Only source 2 (IDS) reliably supports the Indicators API v2 via &lt;code&gt;wbgapi&lt;/code&gt;. Sources 22, 23, and 54 use a different DataBank backend and return JSON decode errors. For QEDS data, use the bulk Excel downloads instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. BigQuery rejects column names starting with numbers.&lt;/strong&gt; Prefix them before writing Parquet. A simple list comprehension handles this: &lt;code&gt;f"year_{col}" if str(col).isdigit() else col&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Use the right tool for the data size.&lt;/strong&gt; PySpark is excellent for large datasets but overkill for a 76K-row Excel file. The GCS Python client with pandas and PyArrow is simpler, faster, and doesn't OOM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Docker-in-Docker with Airflow requires explicit socket mounting and group permissions.&lt;/strong&gt; Mount &lt;code&gt;/var/run/docker.sock&lt;/code&gt; and add the Docker group ID to the worker container. Without the group add, you get a silent permission error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. ELT separates concerns cleanly.&lt;/strong&gt; When the analytical questions evolved during development, I only needed to update dbt models — never the ingestion layer. This separation is the most valuable architectural decision in the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Would Do Differently
&lt;/h2&gt;

&lt;p&gt;If I were building this again, I would use the World Bank DataBank bulk download API to get historical QEDS time series data instead of point-in-time Excel files. The current QEDS data only has a handful of quarters because the Excel files are snapshots of the latest publication. A proper time series would require downloading and archiving each quarterly release.&lt;/p&gt;

&lt;p&gt;I would also add a &lt;code&gt;load_to_bigquery&lt;/code&gt; step that loads Parquet directly into native BigQuery tables rather than using external tables. External tables work well but they require manually updating the source URI list each time a new partition is added. A native table with partitioning handles this automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The Sovereign Debt Observatory took the World Bank's raw debt data from scattered Excel files and API endpoints to a fully automated, tested, and documented analytical pipeline. Every component is reproducible — Terraform provisions the infrastructure, Docker packages the ingestion environment, Airflow schedules the runs, and dbt documents and tests the transformations.&lt;/p&gt;

&lt;p&gt;The full source code is on GitHub: &lt;a href="https://github.com/Derrick-Ryan-Giggs/sovereign-debt-observatory" rel="noopener noreferrer"&gt;https://github.com/Derrick-Ryan-Giggs/sovereign-debt-observatory&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have questions about any part of the implementation, drop them in the comments. I am happy to go deeper on any layer.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Ryan Derrick Giggs is a data engineering practitioner and technical writer based in Nairobi, Kenya. He is currently completing the DataTalksClub Data Engineering Zoomcamp 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;LinkedIn: &lt;a href="https://linkedin.com/in/ryan-giggs-a19330265" rel="noopener noreferrer"&gt;https://linkedin.com/in/ryan-giggs-a19330265&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;GitHub: &lt;a href="https://github.com/Derrick-Ryan-Giggs" rel="noopener noreferrer"&gt;https://github.com/Derrick-Ryan-Giggs&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>gcp</category>
      <category>dbt</category>
      <category>worldbank</category>
    </item>
    <item>
      <title>Exadata AI Storage: A New Era of AI-Powered Database Infrastructure</title>
      <dc:creator>Ryan Giggs</dc:creator>
      <pubDate>Mon, 06 Apr 2026 07:41:04 +0000</pubDate>
      <link>https://dev.to/derrickryangiggs/exadata-ai-storage-a-new-era-of-ai-powered-database-infrastructure-12mb</link>
      <guid>https://dev.to/derrickryangiggs/exadata-ai-storage-a-new-era-of-ai-powered-database-infrastructure-12mb</guid>
      <description>&lt;p&gt;Oracle's Exadata platform has always been synonymous with extreme database performance. But with the release of &lt;strong&gt;Exadata System Software 24ai and 25ai&lt;/strong&gt;, alongside the debut of &lt;strong&gt;Oracle Exadata X11M&lt;/strong&gt; in January 2025, Oracle has taken a decisive step into the AI era. Exadata is no longer just a high-performance OLTP and analytics machine — it is now purpose-built to accelerate AI vector search, in-database machine learning, and mixed enterprise workloads, all on a single converged platform.&lt;/p&gt;

&lt;p&gt;This post breaks down the key new features across software, infrastructure, high availability, monitoring, and security — and explains why they matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is Exadata AI Storage?
&lt;/h2&gt;

&lt;p&gt;At its core, Exadata AI Storage refers to Oracle's strategy of pushing intelligence deeper into the storage layer. Rather than offloading AI computation to separate, purpose-built vector databases or GPUs, Oracle brings AI operations — particularly &lt;strong&gt;AI Vector Search&lt;/strong&gt; — directly to the storage servers themselves. This means vector index builds, similarity searches, and distance calculations happen closer to where data lives, dramatically reducing the data movement that kills performance in distributed architectures.&lt;/p&gt;

&lt;p&gt;The result? Key vector search operations running up to &lt;strong&gt;30x faster&lt;/strong&gt; with Exadata System Software 24ai, and further accelerated on X11M with in-memory vector index (HNSW) scans running &lt;strong&gt;up to 43% faster&lt;/strong&gt; on database servers and &lt;strong&gt;up to 55% faster&lt;/strong&gt; on storage servers compared to the previous generation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key New Software Features
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. AI Smart Scan
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;AI Smart Scan&lt;/strong&gt; is an extension of Exadata's legendary Smart Scan technology, now purpose-built for AI workloads. It offloads compute-intensive &lt;strong&gt;AI Vector Search&lt;/strong&gt; operations — including vector index builds and similarity queries — directly to Exadata's intelligent storage servers. This eliminates the need to ship raw data up to the database tier for processing.&lt;/p&gt;

&lt;p&gt;Critically, AI Smart Scan enables thousands of concurrent AI vector searches in multi-user environments. This is a significant differentiator for enterprise RAG (Retrieval Augmented Generation) pipelines and AI applications that need to serve many users simultaneously, not just batch processes.&lt;/p&gt;

&lt;p&gt;With the latest release (Exadata System Software 25ai / 25.1), &lt;strong&gt;Adaptive Top-K Filtering&lt;/strong&gt; further extends this: each storage server maintains a running Top-K result set, reducing data returned to the database servers by up to &lt;strong&gt;4.7x&lt;/strong&gt;. Similarly, &lt;code&gt;VECTOR_DISTANCE()&lt;/code&gt; calculations are now projected from storage, delivering up to &lt;strong&gt;4.6x faster&lt;/strong&gt; distance-based queries.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Exadata RDMA Memory (XRMEM)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;XRMEM&lt;/strong&gt; replaces the persistent memory (Intel Optane PMem) that earlier generations used, adapting to changes in the memory vendor landscape. It is built on DDR5 DRAM and accessed via &lt;strong&gt;RDMA (Remote Direct Memory Access) over Converged Ethernet (RoCE)&lt;/strong&gt;, bypassing the OS, I/O, and network software stacks entirely.&lt;/p&gt;

&lt;p&gt;The practical benefit: ultra-low read latency as low as &lt;strong&gt;14 microseconds&lt;/strong&gt; — a 21% improvement over prior generations — with scan throughput of up to &lt;strong&gt;500 GB/s&lt;/strong&gt; from XRMEM alone. Each Exadata X11M Extreme Flash Storage Server contains &lt;strong&gt;1.25 TB of XRMEM&lt;/strong&gt;, which sits as an acceleration tier in front of the Smart Flash Cache.&lt;/p&gt;

&lt;p&gt;XRMEM is particularly impactful for OLTP workloads that require sub-20-microsecond response times, and it now also accelerates AI vector index reads transparently.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. On-Storage Processing
&lt;/h3&gt;

&lt;p&gt;This is the foundational principle that makes all the above possible. Exadata storage servers are not passive disk arrays — they are intelligent compute nodes in their own right. SQL filtering, column projection, decompression, encryption/decryption, bloom filters, and now AI vector operations all execute &lt;strong&gt;on the storage servers&lt;/strong&gt;, not the database servers.&lt;/p&gt;

&lt;p&gt;This dramatically reduces the volume of data sent over the internal network and processed by database-tier CPUs. For analytics workloads, this "push-down" processing model is why Exadata consistently delivers 10–100x better throughput than general-purpose storage.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. In-Memory Columnar Speeds for JSON Queries
&lt;/h3&gt;

&lt;p&gt;Exadata's &lt;strong&gt;In-Memory Columnar Cache&lt;/strong&gt; on storage servers (also called Columnar Cache) stores a columnar representation of row-oriented data directly in flash cache. When queries access this data, they get the performance of columnar analytics without requiring data to be reformatted or migrated.&lt;/p&gt;

&lt;p&gt;With &lt;strong&gt;Oracle Database 23ai&lt;/strong&gt; — which is required to unlock the full Exadata Exascale feature set — JSON documents stored natively benefit from this columnar acceleration. Oracle Database 23ai's &lt;strong&gt;JSON Relational Duality&lt;/strong&gt; views, which expose the same data as both JSON and relational tables simultaneously, can be queried at columnar memory speeds on Exadata, collapsing the performance gap between document and relational workloads.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Transparent Cross-Tier Scan
&lt;/h3&gt;

&lt;p&gt;Exadata's multi-tier storage hierarchy — XRMEM → Smart Flash Cache → disk — is managed &lt;strong&gt;automatically and transparently&lt;/strong&gt;. When a Smart Scan or AI Smart Scan runs, Exadata intelligently sources data from whichever tier contains it, combining reads from memory, flash, and disk in parallel.&lt;/p&gt;

&lt;p&gt;This means administrators and developers never need to manually partition hot vs. cold data or tune tier placement. The system continuously tracks access patterns and moves data to the appropriate tier based on usage, keeping the hottest data in XRMEM and the next hottest in flash. The database never sees these tiers explicitly — it simply issues a SQL or vector query and receives results.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Caching Enhancements
&lt;/h3&gt;

&lt;p&gt;Several caching improvements ship with the latest Exadata System Software releases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Automatic KEEP Object Load into Exadata Flash Cache&lt;/strong&gt;: Objects tagged with the &lt;code&gt;KEEP&lt;/code&gt; storage clause in Oracle Database are automatically and proactively loaded into Exadata Smart Flash Cache — even before they are first accessed — ensuring zero cold-start latency for critical tables and indexes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write Back Flash Cache&lt;/strong&gt;: Database block writes are cached in flash, eliminating disk I/O bottlenecks for large OLTP and batch workloads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cell-to-Cell Rebalance preserving XRMEM and Flash Cache&lt;/strong&gt;: When data rebalances across storage servers (due to maintenance or failure), both the XRMEM and flash cache contents are also rebalanced, preserving performance levels rather than causing a cold-cache performance dip.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  7. Columnar Smart Scan at Memory Speed
&lt;/h3&gt;

&lt;p&gt;When Oracle Database In-Memory is enabled, Exadata automatically stores data in &lt;strong&gt;columnar format within Flash Cache and XRMEM&lt;/strong&gt; if it will improve query performance. This brings columnar analytics performance — historically associated only with in-database memory (DRAM on database servers) — to storage-resident data, enabling analytics at memory speeds even when datasets exceed what fits in database-server DRAM.&lt;/p&gt;

&lt;p&gt;A single Exadata X11M rack can deliver up to &lt;strong&gt;100 GB/s of flash throughput&lt;/strong&gt; and &lt;strong&gt;500 GB/s from XRMEM&lt;/strong&gt; for the hottest data, far exceeding what traditional storage arrays can achieve even with flash added.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. Exadata Cache Observability
&lt;/h3&gt;

&lt;p&gt;Exadata System Software &lt;strong&gt;24.1 introduced &lt;code&gt;ecstat&lt;/code&gt;&lt;/strong&gt; (Exadata Storage Cache Statistics), a real-time utility that provides per-storage-server statistics on Smart Flash Cache usage, XRMEM hits, and I/O performance. This was a long-standing gap — DBAs previously had to rely on AWR snapshots to understand cache behavior.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;Exadata System Software 25.2&lt;/strong&gt;, this was extended with &lt;strong&gt;&lt;code&gt;CellSQLStat&lt;/code&gt;&lt;/strong&gt;, which provides real-time, per-storage-server insights into active Smart Scan operations: CPU and memory usage, Storage Index and Columnar Cache I/O savings, flash and XRMEM hit rates, scan rates, and more. Both &lt;code&gt;ecstat&lt;/code&gt; and &lt;code&gt;CellSQLStat&lt;/code&gt; data are automatically included in ExaWatcher collections, making them available for historical analysis.&lt;/p&gt;

&lt;h2&gt;
  
  
  Infrastructure Improvements
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Increased Number of Virtual Machines
&lt;/h3&gt;

&lt;p&gt;With &lt;strong&gt;Exadata Exascale&lt;/strong&gt; (the new intelligent storage architecture introduced in 2024 and available on X11M), the limit on Virtual Machine clusters per database server increases dramatically. Traditional Exadata with ASM supported 4, 8, or 12 VMs per database server. Exascale raises this ceiling to &lt;strong&gt;50 VMs per database server&lt;/strong&gt;, enabling far greater consolidation of Oracle Database workloads on a single Exadata system without sacrificing isolation or performance.&lt;/p&gt;

&lt;p&gt;Exascale also centralizes VM storage in the shared Exascale storage pool rather than on individual database servers, increasing flexibility and simplifying management.&lt;/p&gt;

&lt;h3&gt;
  
  
  Secure Boot for KVM Virtual Machines
&lt;/h3&gt;

&lt;p&gt;Exadata System Software now supports &lt;strong&gt;Secure Boot for KVM guest VMs&lt;/strong&gt;, ensuring that only cryptographically signed and trusted OS images can boot on Exadata database servers. This closes a significant attack vector in virtualized deployments and aligns Exadata's on-premises security posture with cloud-native security standards. It complements existing features like Trusted Partitions for Oracle Linux Virtualization.&lt;/p&gt;

&lt;h2&gt;
  
  
  High Availability and Network Resilience
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Improved RoCE Network Resilience: ExaPortMon
&lt;/h3&gt;

&lt;p&gt;Every Exadata database and storage server connects to the internal network via &lt;strong&gt;dual 100 GbE RoCE ports&lt;/strong&gt; for an aggregate of 200 Gbps. If a RoCE leaf switch port becomes stalled — appearing online but unable to pass traffic — it can cause cluster instability without triggering a clean failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ExaPortMon&lt;/strong&gt; (introduced in Exadata System Software 24ai) solves this. It continuously monitors both RoCE ports on every server. When it detects a stalled port, it &lt;strong&gt;automatically migrates the IP address to the healthy port&lt;/strong&gt;, keeping network traffic flowing and preventing outages. When the stalled port recovers, ExaPortMon automatically returns the IP address to its original port. No manual intervention required.&lt;/p&gt;

&lt;h3&gt;
  
  
  Enhanced RoCE Network Security: Exadata Secure RDMA Fabric Isolation
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Exadata Secure RDMA Fabric Isolation (Secure Fabric)&lt;/strong&gt; provides strict network isolation between VM clusters sharing the same physical Exadata infrastructure. It prevents database servers in one VM cluster from communicating with those in another over the RoCE fabric, eliminating lateral movement risk in consolidated and multi-tenant deployments.&lt;/p&gt;

&lt;p&gt;Starting with Exadata System Software 25.1, &lt;strong&gt;Secure Fabric is automatically selected by default&lt;/strong&gt; for all new on-premises deployments with X8M and newer hardware — bringing on-premises deployments into alignment with cloud deployments, which have always used Secure Fabric.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoring and Management
&lt;/h2&gt;

&lt;h3&gt;
  
  
  AWR &amp;amp; SQL Monitor Enhancements
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Oracle Automatic Workload Repository (AWR)&lt;/strong&gt; on Exadata is enhanced with &lt;strong&gt;Exadata-specific storage server metrics&lt;/strong&gt; alongside standard Oracle wait event data. AWR now collects and reports on XRMEM, Flash Cache, and HDD device performance, enabling DBAs to correlate database wait events with storage-tier behavior in a single report.&lt;/p&gt;

&lt;p&gt;SQL Monitor is similarly enhanced, providing end-to-end visibility into query execution that includes storage offload statistics, Smart Scan I/O savings, and flash cache hit rates — all tied to the specific SQL statement being analyzed.&lt;/p&gt;

&lt;h3&gt;
  
  
  JSON API for Management Server
&lt;/h3&gt;

&lt;p&gt;Exadata's Management Server (MS) now exposes a &lt;strong&gt;JSON REST API&lt;/strong&gt;, enabling programmatic access to Exadata management and monitoring functions. This is a significant modernization of Exadata's management interface, making it easier to integrate Exadata health metrics, alerts, and configuration into modern observability stacks (Grafana, custom dashboards, CI/CD pipelines) without relying solely on traditional CLI tools like &lt;code&gt;cellcli&lt;/code&gt; or Enterprise Manager.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security Enhancements
&lt;/h2&gt;

&lt;h3&gt;
  
  
  SNMP v3 Security
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Exadata System Software 24.1&lt;/strong&gt; introduced mandatory SNMP security improvements across all storage servers and database servers. The key changes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SNMP v3 is now the recommended and encouraged standard&lt;/strong&gt;, supporting SHA-256, SHA-384, and SHA-512 authentication protocols for strong authentication and encryption.&lt;/li&gt;
&lt;li&gt;All SNMP subscriber definitions now &lt;strong&gt;require the connection type to be explicitly specified&lt;/strong&gt; — administrators can no longer leave it ambiguous.&lt;/li&gt;
&lt;li&gt;SNMP v1 remains available but triggers an explicit warning discouraging its use.&lt;/li&gt;
&lt;li&gt;Default community strings like &lt;code&gt;public&lt;/code&gt; and &lt;code&gt;private&lt;/code&gt; are &lt;strong&gt;actively discouraged by the system&lt;/strong&gt;, prompting administrators to set strong, unique values.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This tightens Exadata's management plane security, closing a common vulnerability in enterprise infrastructure where SNMP v1 with default community strings is still widely used.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This All Matters: The Convergence Thesis
&lt;/h2&gt;

&lt;p&gt;The strategic bet Oracle is making with Exadata AI Storage is one of &lt;strong&gt;convergence over fragmentation&lt;/strong&gt;. The enterprise AI market has seen an explosion of purpose-built vector databases (Pinecone, Weaviate, Qdrant, Milvus, Chroma), and many organizations are building RAG pipelines that shuffle data between separate systems: a relational database for operational data, a vector database for embeddings, an object store for documents.&lt;/p&gt;

&lt;p&gt;Exadata offers a fundamentally different architecture: &lt;strong&gt;bring the AI to the data, not the data to the AI&lt;/strong&gt;. With Oracle AI Database 23ai (now succeeded by Oracle AI Database 26ai as the long-term support release), all of this runs in a single converged engine — relational queries, vector similarity search, JSON document queries, graph traversals, and full-text search — executed as optimized SQL on Exadata hardware. Advanced AI features including AI Vector Search are included at no additional charge.&lt;/p&gt;

&lt;p&gt;And with &lt;strong&gt;Exadata Exascale&lt;/strong&gt; reducing the entry cost for Exadata Database Service by up to 95% and enabling organizations to start with as little as 300 GB of storage, the platform is no longer exclusively for Fortune 500 database estates. It is increasingly accessible to organizations of any size that need to build AI applications on enterprise-grade, governed, transactionally consistent data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Key Feature&lt;/th&gt;
&lt;th&gt;Benefit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AI Workloads&lt;/td&gt;
&lt;td&gt;AI Smart Scan + Adaptive Top-K&lt;/td&gt;
&lt;td&gt;Up to 30x faster vector search; 4.7x less data to DB servers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory&lt;/td&gt;
&lt;td&gt;XRMEM (DDR5 + RDMA)&lt;/td&gt;
&lt;td&gt;14µs read latency; 500 GB/s scan throughput&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caching&lt;/td&gt;
&lt;td&gt;Auto KEEP Load, Write Back, Columnar Cache&lt;/td&gt;
&lt;td&gt;Zero cold-start for critical objects; analytics at memory speed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observability&lt;/td&gt;
&lt;td&gt;ecstat + CellSQLStat&lt;/td&gt;
&lt;td&gt;Real-time per-cell Smart Scan and cache monitoring&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infrastructure&lt;/td&gt;
&lt;td&gt;Exascale VM limit increase&lt;/td&gt;
&lt;td&gt;Up to 50 VMs per DB server (up from 12)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security&lt;/td&gt;
&lt;td&gt;Secure Fabric default on-prem&lt;/td&gt;
&lt;td&gt;Automatic lateral-movement isolation for VM clusters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network HA&lt;/td&gt;
&lt;td&gt;ExaPortMon&lt;/td&gt;
&lt;td&gt;Auto-failover between RoCE ports; no manual intervention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security&lt;/td&gt;
&lt;td&gt;SNMP v3 enforcement&lt;/td&gt;
&lt;td&gt;SHA-512 auth; eliminates default community string risk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Management&lt;/td&gt;
&lt;td&gt;JSON API for Management Server&lt;/td&gt;
&lt;td&gt;Programmatic integration with modern observability stacks&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Exadata AI Storage represents Oracle's clearest articulation yet of its "converged data" strategy: a single platform that handles OLTP, analytics, and AI workloads without requiring organizations to build and manage a fragmented ecosystem of specialized tools. With Exadata System Software 25ai, the X11M generation, and the Exascale architecture now generally available across OCI, multicloud (AWS, Azure, Google Cloud), and on-premises, there has never been a better time to evaluate what Exadata can do for your AI application stack.&lt;/p&gt;

&lt;p&gt;The numbers speak for themselves — but the architecture is the real story.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Have you worked with Exadata AI Storage or Oracle Database 23ai/26ai AI Vector Search? Share your experience in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>oracle</category>
      <category>exadata</category>
      <category>databaseengineering</category>
      <category>aiinfrastructure</category>
    </item>
  </channel>
</rss>
