<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Databases - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Databases - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Tue, 01 Sep 2026 01:00:22 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/databases/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ From Data to Value: Understanding Data Management Through a Real World Use Case [Full Book] ]]>
                </title>
                <description>
                    <![CDATA[ Today, data has become a particularly valuable resource. It allows companies to compete in the market and drive innovation, improving the quality of products and services offered. Data processing lets ]]>
                </description>
                <link>https://www.freecodecamp.org/news/understanding-data-management-with-a-real-world-use-case-book/</link>
                <guid isPermaLink="false">6a8db9c20b9b2c87b7c4049f</guid>
                
                    <category>
                        <![CDATA[ data management ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Data Science ]]>
                    </category>
                
                    <category>
                        <![CDATA[ book ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Databases ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Daniel García Solla ]]>
                </dc:creator>
                <pubDate>Tue, 25 Aug 2026 15:50:26 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/b9817dc0-f0dc-4ccf-a8f7-2e7b47783360.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Today, data has become a particularly valuable resource. It allows companies to compete in the market and drive innovation, improving the quality of products and services offered.</p>
<p>Data processing lets teams automate processes. It also supports decision-making, offers a significantly more personalized experience to the end user, and detects patterns in many areas such as banking fraud or risk mitigation. Companies need to know how to capture and use data effectively, safely, and legally.</p>
<p>You likely are or have been a user of various products and services. And you know that processes involving data are fundamental to almost everything around us. You're likely also already familiar with terms like Big Data, Data Analytics, Artificial Intelligence, and Machine Learning.</p>
<p>But unless you're an expert in one of these fields, some of these concepts might seem overwhelming. These are large areas of study, after all.</p>
<p>And even if you're trained in one of these areas, it's difficult to know all the details about each field, as the data world is vast.</p>
<p>One way to understand this world of data a bit better is by dividing it, and establishing a distinction between the areas of Artificial Intelligence and Data Management. This isn't the only way to proceed, but I've found it helpful to separate the set of disciplines and techniques for information processing into these two blocks.</p>
<p>On one side is Data Management, which encompasses everything related to the capture, storage, protection, and analysis of data.</p>
<p>Meanwhile, on the other side is Artificial Intelligence, which focuses on developing techniques that allow a machine to emulate human capabilities like reasoning or learning to solve a problem, whether interacting with data or not.</p>
<p>Here, interaction refers to an algorithm acquiring "knowledge" from data, but not all artificial intelligence functions.</p>
<p>In any case, this book offers a comprehensive overview of Data Management, helping you understand all the terms and related concepts involved in using, processing, and analyzing data.</p>
<p>It won't just provide an abstract explanation of the field and its contents. It'll instead help you understand it holistically and offer a more practical and realistic view. We'll also study a use case to put into practice everything we discuss.</p>
<h2 id="heading-table-of-contents">Table Of Contents</h2>
<ul>
<li><p><a href="#heading-our-case-study">Our Case Study</a></p>
</li>
<li><p><a href="#heading-data-management-fundamentals">Data Management Fundamentals</a></p>
<ul>
<li><p><a href="#heading-data-as-an-asset">Data as an Asset</a></p>
</li>
<li><p><a href="#heading-data-information-knowledge-and-value">Data, Information, Knowledge, and Value</a></p>
</li>
<li><p><a href="#heading-the-data-lifecycle">The Data Lifecycle</a></p>
</li>
<li><p><a href="#heading-data-management-principles">Data Management Principles</a></p>
</li>
<li><p><a href="#heading-data-management-capabilities">Data Management Capabilities</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-data-governance">Data Governance</a></p>
<ul>
<li><p><a href="#heading-data-ownership">Data Ownership</a></p>
</li>
<li><p><a href="#heading-data-stewardship">Data Stewardship</a></p>
</li>
<li><p><a href="#heading-decision-rights">Decision Rights</a></p>
</li>
<li><p><a href="#heading-data-policies">Data Policies</a></p>
</li>
<li><p><a href="#heading-data-standards">Data Standards</a></p>
</li>
<li><p><a href="#heading-data-accountability">Data Accountability</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-data-ethics">Data Ethics</a></p>
<ul>
<li><p><a href="#heading-ethical-data-use">Ethical Data Use</a></p>
</li>
<li><p><a href="#heading-consent-and-transparency">Consent and Transparency</a></p>
</li>
<li><p><a href="#heading-fairness-and-non-discrimination">Fairness and Non-Discrimination</a></p>
</li>
<li><p><a href="#heading-responsible-data-sharing">Responsible Data Sharing</a></p>
</li>
<li><p><a href="#heading-ethical-risk-management">Ethical Risk Management</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-data-security-and-privacy">Data Security and Privacy</a></p>
<ul>
<li><p><a href="#heading-data-classification">Data Classification</a></p>
</li>
<li><p><a href="#heading-identity-and-access-management">Identity and Access Management</a></p>
</li>
<li><p><a href="#heading-encryption">Encryption</a></p>
</li>
<li><p><a href="#heading-data-masking">Data Masking</a></p>
</li>
<li><p><a href="#heading-privacy-controls">Privacy Controls</a></p>
</li>
<li><p><a href="#heading-audit-and-compliance">Audit and Compliance</a></p>
</li>
<li><p><a href="#heading-security-operations-secops">Security Operations (SecOps)</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-data-architecture">Data Architecture</a></p>
<ul>
<li><p><a href="#heading-enterprise-data-architecture">Enterprise Data Architecture</a></p>
</li>
<li><p><a href="#heading-data-domains">Data Domains</a></p>
</li>
<li><p><a href="#heading-data-flows">Data Flows</a></p>
</li>
<li><p><a href="#heading-operational-data-architecture">Operational Data Architecture</a></p>
</li>
<li><p><a href="#heading-analytical-data-architecture">Analytical Data Architecture</a></p>
</li>
<li><p><a href="#heading-cloud-and-hybrid-data-architectures">Cloud and Hybrid Data Architectures</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-data-modeling-and-design">Data Modeling and Design</a></p>
<ul>
<li><p><a href="#heading-conceptual-data-models">Conceptual Data Models</a></p>
</li>
<li><p><a href="#heading-logical-data-models">Logical Data Models</a></p>
</li>
<li><p><a href="#heading-physical-data-models">Physical Data Models</a></p>
</li>
<li><p><a href="#heading-entity-relationship-modeling">Entity-Relationship Modeling</a></p>
</li>
<li><p><a href="#heading-dimensional-modeling">Dimensional Modeling</a></p>
</li>
<li><p><a href="#heading-data-model-governance">Data Model Governance</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-data-storage-and-operations">Data Storage and Operations</a></p>
<ul>
<li><p><a href="#heading-databases">Databases</a></p>
</li>
<li><p><a href="#heading-file-and-object-storage">File and Object Storage</a></p>
</li>
<li><p><a href="#heading-data-warehouses">Data Warehouses</a></p>
</li>
<li><p><a href="#heading-data-lakes-and-lakehouses">Data Lakes and Lakehouses</a></p>
</li>
<li><p><a href="#heading-backup-and-recovery">Backup and Recovery</a></p>
</li>
<li><p><a href="#heading-retention-and-archiving">Retention and Archiving</a></p>
</li>
<li><p><a href="#heading-performance-and-availability">Performance and Availability</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-document-and-content-management">Document and Content Management</a></p>
<ul>
<li><p><a href="#heading-unstructured-data">Unstructured Data</a></p>
</li>
<li><p><a href="#heading-document-capture">Document Capture</a></p>
</li>
<li><p><a href="#heading-document-classification">Document Classification</a></p>
</li>
<li><p><a href="#heading-content-storage">Content Storage</a></p>
</li>
<li><p><a href="#heading-search-and-retrieval">Search and Retrieval</a></p>
</li>
<li><p><a href="#heading-records-management">Records Management</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-reference-and-master-data-management">Reference and Master Data Management</a></p>
<ul>
<li><p><a href="#heading-master-data">Master Data</a></p>
</li>
<li><p><a href="#heading-reference-data">Reference Data</a></p>
</li>
<li><p><a href="#heading-golden-records">Golden Records</a></p>
</li>
<li><p><a href="#heading-entity-resolution">Entity Resolution</a></p>
</li>
<li><p><a href="#heading-deduplication">Deduplication</a></p>
</li>
<li><p><a href="#heading-survivorship-rules">Survivorship Rules</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-metadata-management">Metadata Management</a></p>
<ul>
<li><p><a href="#heading-business-metadata">Business Metadata</a></p>
</li>
<li><p><a href="#heading-technical-metadata">Technical Metadata</a></p>
</li>
<li><p><a href="#heading-operational-metadata">Operational Metadata</a></p>
</li>
<li><p><a href="#heading-data-catalogs">Data Catalogs</a></p>
</li>
<li><p><a href="#heading-business-glossaries">Business Glossaries</a></p>
</li>
<li><p><a href="#heading-data-lineage">Data Lineage</a></p>
</li>
<li><p><a href="#heading-metadata-standards">Metadata Standards</a></p>
</li>
<li><p><a href="#heading-metadata-quality">Metadata Quality</a></p>
</li>
<li><p><a href="#heading-metadata-governance">Metadata Governance</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-data-integration-and-interoperability">Data Integration and Interoperability</a></p>
<ul>
<li><p><a href="#heading-data-ingestion">Data Ingestion</a></p>
</li>
<li><p><a href="#heading-batch-integration">Batch Integration</a></p>
</li>
<li><p><a href="#heading-streaming-integration">Streaming Integration</a></p>
</li>
<li><p><a href="#heading-api-based-integration">API-Based Integration</a></p>
</li>
<li><p><a href="#heading-etl-and-elt">ETL and ELT</a></p>
</li>
<li><p><a href="#heading-data-exchange-standards">Data Exchange Standards</a></p>
</li>
<li><p><a href="#heading-schema-management">Schema Management</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-data-quality">Data Quality</a></p>
<ul>
<li><p><a href="#heading-data-quality-dimensions">Data Quality Dimensions</a></p>
</li>
<li><p><a href="#heading-data-profiling">Data Profiling</a></p>
</li>
<li><p><a href="#heading-data-quality-rules">Data Quality Rules</a></p>
</li>
<li><p><a href="#heading-data-validation">Data Validation</a></p>
</li>
<li><p><a href="#heading-data-cleansing">Data Cleansing</a></p>
</li>
<li><p><a href="#heading-data-quality-monitoring">Data Quality Monitoring</a></p>
</li>
<li><p><a href="#heading-issue-management">Issue Management</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-data-engineering">Data Engineering</a></p>
<ul>
<li><p><a href="#heading-data-pipelines">Data Pipelines</a></p>
</li>
<li><p><a href="#heading-pipeline-orchestration">Pipeline Orchestration</a></p>
</li>
<li><p><a href="#heading-data-transformation">Data Transformation</a></p>
</li>
<li><p><a href="#heading-workflow-automation">Workflow Automation</a></p>
</li>
<li><p><a href="#heading-data-testing">Data Testing</a></p>
</li>
<li><p><a href="#heading-data-versioning">Data Versioning</a></p>
</li>
<li><p><a href="#heading-data-platform-operations">Data Platform Operations</a></p>
</li>
<li><p><a href="#heading-data-observability">Data Observability</a></p>
</li>
<li><p><a href="#heading-data-contracts">Data Contracts</a></p>
</li>
<li><p><a href="#heading-dataops">DataOps</a></p>
</li>
<li><p><a href="#heading-devops">DevOps</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-data-warehousing-and-business-intelligence">Data Warehousing and Business Intelligence</a></p>
<ul>
<li><p><a href="#heading-analytical-data-stores">Analytical Data Stores</a></p>
</li>
<li><p><a href="#heading-facts-and-dimensions">Facts and Dimensions</a></p>
</li>
<li><p><a href="#heading-metrics-and-kpis">Metrics and KPIs</a></p>
</li>
<li><p><a href="#heading-semantic-layers">Semantic Layers</a></p>
</li>
<li><p><a href="#heading-reports-and-dashboards">Reports and Dashboards</a></p>
</li>
<li><p><a href="#heading-self-service-analytics">Self-Service Analytics</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-big-data">Big Data</a></p>
<ul>
<li><p><a href="#heading-the-3vs-volume-velocity-and-variety">The 3Vs: Volume, Velocity, and Variety</a></p>
</li>
<li><p><a href="#heading-big-data-architectures">Big Data Architectures</a></p>
</li>
<li><p><a href="#heading-big-data-storage-and-processing">Big Data Storage and Processing</a></p>
</li>
<li><p><a href="#heading-big-data-analytics">Big Data Analytics</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-analytics-and-data-science">Analytics and Data Science</a></p>
<ul>
<li><p><a href="#heading-analytical-datasets">Analytical Datasets</a></p>
</li>
<li><p><a href="#heading-exploratory-data-analysis">Exploratory Data Analysis</a></p>
</li>
<li><p><a href="#heading-feature-engineering">Feature Engineering</a></p>
</li>
<li><p><a href="#heading-experimentation">Experimentation</a></p>
</li>
<li><p><a href="#heading-model-ready-data">Model-Ready Data</a></p>
</li>
<li><p><a href="#heading-analytical-product-delivery">Analytical Product Delivery</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-data-products">Data Products</a></p>
<ul>
<li><p><a href="#heading-product-characteristics">Product Characteristics</a></p>
</li>
<li><p><a href="#heading-ownership-and-lifecycle">Ownership and Lifecycle</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-data-management-organization">Data Management Organization</a></p>
<ul>
<li><p><a href="#heading-operating-model">Operating Model</a></p>
</li>
<li><p><a href="#heading-roles-and-collaboration">Roles and Collaboration</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-data-management-maturity">Data Management Maturity</a></p>
<ul>
<li><p><a href="#heading-maturity-levels">Maturity Levels</a></p>
</li>
<li><p><a href="#heading-assessment-and-roadmap">Assessment and Roadmap</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-conclusions">Conclusions</a></p>
</li>
</ul>
<h2 id="heading-our-case-study">Our Case Study</h2>
<p>Our use case involves a fictional university offering international master's programs in Artificial Intelligence and Data Management. It's a public-private institution providing various training programs for different end users, such as recent graduates looking to specialize in this area, working professionals, or international students.</p>
<p>This use case lets us analyze the entire data lifecycle, from student admission to graduation. Also, in a university setting, we can use data alongside artificial intelligence to automate enrollment processes, enhance the student's experience when accessing educational resources, optimize organizational operations, and ultimately help the university differentiate itself from other institutions offering similar programs.</p>
<p>The data lifecycle begins before enrollment in a master's program, as a candidate might discover the program through an advertising campaign, visit the institution's website, or complete an application form. They can then enroll and attend classes, using digital platforms and participating in various educational activities. Finally, they'll complete the program and become part of the alumni community.</p>
<p>Each of these interactions generates different types of data, such as personal, academic, administrative, and financial data. There are also more complex types of data, like activity and digital behavior data, which can include records of access to the virtual campus or consulted resources, among others.</p>
<p>This journey allows us to see how data goes through different phases. We'll see how it's captured, validated, stored, integrated with other systems, protected, analyzed, and finally retained or deleted according to the organization's policies.</p>
<p>As you can imagine, Data Management isn't just about storing data in a database. It's also about ensuring that, throughout its lifecycle, the data is accurate, secure, understandable, accessible to those who need it, and used legitimately.</p>
<p>Also, the university, like any other entity, uses data to identify the needs or problems of its users in order to propose solutions. One such issue could be commuting, as some students in the master's programs live far from campus, others might work, and still others may have poor public transportation options. In these cases, distance or travel time becomes a decisive factor for those students.</p>
<p>Faced with this seemingly complex issue, the university can use data and artificial intelligence techniques to plan and offer suitable transportation services to certain interested students. This means, based on eligibility criteria such as the distance from campus or enrollment in mandatory in-person classes, the university can plan to offer free taxi/VTC services to certain students.</p>
<p>But the idea wouldn't be to provide unlimited taxi services to all students – just to design a controlled, measurable, and sustainable benefit based on clear business rules.</p>
<p>Processing this data effectively would allow the university to offer a more precise service than other competitors, who might offer generic public transportation discounts or fixed bus routes. And while these solutions might be very useful, they don't always adequately meet the needs of all students.</p>
<p>In this scenario, it's clear that a wide variety of data is generated, including data on students, faculty, courses, schedules, attendance records, trips taken, and so on. Using and analyzing this data, we'll be able to learn many Data Management principles. We'll also demonstrate how data pipelines are built, how data is transformed into useful analytical products, and what techniques are involved.</p>
<p>To make these ideas easier to follow in practice, this book is accompanied by a <a href="https://github.com/cardstdani/sql-storage/blob/345ff1e13c684e4ae0127c8a1d30af640dfdbcad/Data_Management.ipynb"><strong>hands-on Jupyter notebook</strong></a>. It uses a compact sample of real taxi-trip data and treats it as a provider feed for the university's transportation service.</p>
<p>Some of the examples discussed throughout the book are reproduced in the notebook with the same dataset, so as you move through the chapters, you can see selected concepts in action, including data profiling, quality rules, integration, transformation, privacy protection, dimensional modeling, SQL analysis, and visualization. It's a focused demonstration rather than a complete implementation of every capability discussed here.</p>
<p>You'll also learn how the university might use artificial intelligence to predict which candidates are most likely to enroll, recommend master's programs, estimate future demand for mobility services, detect unusual patterns in taxi usage, and create conversational assistants to help candidates and students resolve their questions.</p>
<p>This case study will also highlight the university's need to make decisions about privacy, consent, transparency, and security. For example, personal data must be protected, eligibility rules should not unfairly discriminate, and human oversight should be established for decisions that could significantly impact a candidate or student.</p>
<h2 id="heading-data-management-fundamentals">Data Management Fundamentals</h2>
<p>Data Management is the discipline responsible for capturing, storing, protecting, integrating, understanding, maintaining, and correctly using data throughout its lifecycle. At first glance, management and processing might seem to involve only storage and perhaps later analysis, but nothing could be further from the truth.</p>
<p>There are many more requirements like security (as managing large volumes of information quickly is useless if security is compromised) as well as data integrity and organization.</p>
<p>While researching for this book, I studied the very useful book <a href="https://dama.org/learning-resources/dama-data-management-body-of-knowledge-dmbok/"><strong>Data Management Body of Knowledge</strong></a> <strong>(DAMA-DMBOK)</strong>. It's one of the most comprehensive and reputable guides on the world of data. And I highly recommend it if you want to dive even deeper here.</p>
<p>According to the book, Data Management involves the development, execution, and supervision of plans, policies, programs, and practices that enable the delivery, control, protection, and enhancement of the value of data and information assets throughout their lifecycle.</p>
<p>This definition is especially relevant because it highlights two fundamental ideas. One is that data has intrinsic value, allowing it to be treated as an asset. The other is that this value doesn't appear directly in all cases but depends on how the data is managed.</p>
<p>In other words, data alone has no value, but if you process it properly, it has the potential to become usable information and subsequently knowledge.</p>
<p>To achieve this goal, you can think about Data Management as a set of <strong>operational capabilities</strong>, meaning the various actions a team or organization must undertake regarding its data.</p>
<p>Among the most fundamental are the following:</p>
<ul>
<li><p><strong>Data Governance:</strong> deciding who has access to each piece of data and who sets the access rules.</p>
<ul>
<li><em>Example:</em> University faculty may have access to certain data about students in their courses, but not about any student in the organization.</li>
</ul>
</li>
<li><p><strong>Data Architecture:</strong> designing the processes that data will follow throughout its lifecycle.</p>
<ul>
<li><em>Example:</em> A data architect defines how data travels from the moment a user enters it into the system, such as during an enrollment form, to where it's stored and processed internally on the university server.</li>
</ul>
</li>
<li><p><strong>Data Storage and Operations:</strong> deciding how and where the data is stored.</p>
<ul>
<li><em>Example:</em> The decision is made to store students' personal data in an internal database, as opposed to alternatives like storing it in an external cloud service. Meanwhile, other data, such as educational materials, are more likely to end up stored in the cloud, although it ultimately depends on the organization's policies.</li>
</ul>
</li>
<li><p><strong>Data Integration:</strong> gathering information from different sources to provide a unified view or access to all of them.</p>
<ul>
<li><em>Example:</em> A data engineer integrates information from different sources about taxi routes, as each company will have its own source with unique characteristics, making it necessary to standardize the data into an intermediate schema.</li>
</ul>
</li>
<li><p><strong>Data Quality:</strong> ensuring that the information is accurate, complete, consistent, up-to-date, and reliable.</p>
<ul>
<li><em>Example:</em> A quality analyst defines the rules that the virtual campus frontend must follow to prevent end users from entering incorrect data into the system, ensuring its quality. They also impose rules on the various internal systems where the information is stored to avoid inconsistencies.</li>
</ul>
</li>
<li><p><strong>Data Security and Privacy:</strong> protecting information against unauthorized access and other threats.</p>
<ul>
<li><em>Example:</em> User access passwords are stored as <a href="https://youtu.be/zt8Cocdy15c?si=eGz4JOsnsjv_WcLP"><strong>hashed</strong></a> values, not in plain text, to prevent easy access in case of a potential vulnerability.</li>
</ul>
</li>
<li><p><strong>Metadata Management:</strong> specifically managing the data that determines the meaning of other data.</p>
<ul>
<li><em>Example:</em> A glossary is created with terms that define the meaning of each concept represented in the data. One of them could be "distance to campus in meters." In this case, the meaning is clear, and its inclusion in the glossary allows it to be used in the implementation of storage systems and data processing, facilitating development.</li>
</ul>
</li>
<li><p><strong>Analytics and Business Intelligence:</strong> transforming data into reports and visual indicators that facilitate strategic decision-making within the organization.</p>
<ul>
<li><em>Example:</em> A data analyst creates an interactive dashboard for the administration, displaying graphs of monthly taxi expenses, the number of students benefiting, and how this service has improved the percentage of attendance in in-person classes.</li>
</ul>
</li>
</ul>
<p>So as you can see, Data Management isn't a specific activity but a collection of many different tasks and processes. When coordinated, these allow data to be transformed into strategic value.</p>
<p>In the university use case, it's clear that the personal data of applicants and students must be protected. Also, to help implement the free taxi service, the data sources from different transportation companies must be well-integrated and of high quality.</p>
<h3 id="heading-data-as-an-asset">Data as an Asset</h3>
<p>Data can be defined as a symbolic representation of a quantitative or qualitative attribute or variable. In other words, data are representations of facts, observations, events, or characteristics occurring in an environment, which can later be stored and processed.</p>
<p>This definition of data relates more to its types, such as numbers, dates, text, or images. In our use case, data might include a student's name, address, or the distance from their home to the campus. Each of these, in isolation, is a simple record, but when contextualized and analyzed together, they have the potential to become an asset.</p>
<p>For instance, an isolated piece of data like "18 kilometers" isn't very relevant by itself. But if it's interpreted as the characteristic "distance to campus", it becomes useful for understanding a student's situation and making a decision.</p>
<p>In this context, an asset is any resource expected to yield a return in the future, like buildings, patents, or other elements. Here, we're also including data because of its potential to generate value within the organization.</p>
<p>But this doesn't mean that just any piece of data is an asset. Data can be incorrect, duplicated, or incomplete. So its value mainly depends on how it's managed. For example, at the university, "distance to campus" becomes an asset when it's not used as an isolated number but rather for decision-making.</p>
<p>In our example, the distance from campus along with other student and organizational data can help us decide which students are eligible for this taxi service or how much budget should be allocated for it.</p>
<p>Data that's considered an asset can help drive these decisions only when the quality is adequate, because incomplete, inconsistent, or erroneous data can affect this process negatively or not contribute to the decision.</p>
<p>Ultimately, considering data as assets means treating it as a resource that requires specific management. And this can lead to benefits you wouldn't be able to achieve otherwise, whether it's improved end-user satisfaction or cost optimization.</p>
<h3 id="heading-data-information-knowledge-and-value">Data, Information, Knowledge, and Value</h3>
<p>From this idea arises the distinction between data, information, knowledge, and value. We'll study the progressive transformation that turns data into useful knowledge and ultimately into value for an organization.</p>
<h4 id="heading-data">Data</h4>
<p>First, data is the most basic unit dealt with in Data Management, and its main function is to represent an aspect of reality. That is, data is what we imagine when we think of something like a number, some text, a date, and so on. Data has types (because of its variety), and also has a basic meaning associated, generally called semantics.</p>
<ul>
<li><em>Example:</em> "18 kilometers" is a piece of data of the integer type, and its semantics indicate that it represents a quantity of kilometers. Here, it's important to realize that the quantity alone might be considered data, but its semantics allow for interpretation.</li>
</ul>
<h4 id="heading-information">Information</h4>
<p>Once we have isolated data, we can relate and contextualize it to create a more abstract meaning, which is considered information.</p>
<ul>
<li><em>Example:</em> To better understand this concept, the previous data "18 kilometers" can be contextualized with other information like a student's name or address, allowing us to infer that the student lives that far from the campus. This is considered information, as its semantics go beyond that of a simple piece of data.</li>
</ul>
<h4 id="heading-knowledge">Knowledge</h4>
<p>After obtaining information, we can then analyze and interpret it to identify patterns, trends, or cause-and-effect relationships. We do this by integrating the information and observing the prior experience of the organization or similar ones, creating an even more abstract contextualization.</p>
<ul>
<li><em>Example:</em> If the university observes that students living more than 15 kilometers away and having in-person classes miss more classes, it can conclude that distance and schedule influence attendance. This requires information such as the students' distance from campus or their attendance records and schedules.</li>
</ul>
<h4 id="heading-value">Value</h4>
<p>Finally, we use knowledge in decision-making and taking actions that can generate a benefit, which is the value derived from the data.</p>
<ul>
<li><em>Example:</em> The university can offer free taxi services only to specific students who meet certain criteria, improving attendance and user satisfaction while minimizing the impact on the budget. Here, the value lies in the benefit gained from these decisions, which may or may not be easily measurable.</li>
</ul>
<p>In summary, success doesn't lie solely in storing large volumes of data or processing them at high speed, but in advancing them through this sequence of transformations to turn them into value. This process requires an infrastructure suited to these needs, as well as qualified people who are capable of applying the appropriate Data Management techniques.</p>
<h3 id="heading-the-data-lifecycle">The Data Lifecycle</h3>
<p>Now let's look at the stages data goes through. Its lifecycle starts when the organization identifies a need for it and ends when the data is no longer useful. Between those points, teams capture, store, maintain, use, and eventually retain or delete the data. The lifecycle describes the phases that keep this journey controlled.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66b716b04709012ee58fbbdc/29ee988f-16cd-46b9-a2a1-237c1674c898.png" alt="The data lifecycle diagram. Image by author." style="display:block;margin:0 auto" width="1448" height="1086" loading="lazy">

<p>As the diagram shows, the lifecycle starts with business needs, not technology choices. Because data is an organizational asset, each phase should help protect it, maintain it, or turn it into value.</p>
<p>The lifecycle consists of the following phases (as in the graphic above):</p>
<ul>
<li><p><strong>Planning:</strong> The organization decides what data it needs, why it needs it, who will be responsible for it, and how it could create value. Before capturing anything, the team should know which data is truly necessary and what they expect to do with it.</p>
<ul>
<li><em>Example:</em> The university decides it needs to know the distance between a student's home and the campus to evaluate whether it can offer free taxi service, explaining why it's necessary and what decision it will allow later.</li>
</ul>
</li>
<li><p><strong>Design and Enablement:</strong> Once the need is clear, the team designs the infrastructure, data flows, and policies that will support it. This work draws on capabilities such as data architecture, modeling, security, quality, and governance, which we'll discuss later.</p>
<ul>
<li><em>Example:</em> The university defines that the distance to the campus will be calculated from the address provided by the student, that the data will be stored in a specific system, that only certain departments will have access to it, and that it must be updated if the student changes their address.</li>
</ul>
</li>
<li><p><strong>Creation or Acquisition:</strong> At this stage, the data enters the organization for the first time. A user might create it through an interaction, or the organization might obtain it from an external source through an API, exchange, purchase, or integration.</p>
<ul>
<li><em>Example:</em> The data is created when the applicant completes the admission form indicating their address. External data such as geographic information or estimates of distance and travel time from a geographic API could also be obtained.</li>
</ul>
</li>
<li><p><strong>Storage and Maintenance:</strong> Once captured, the data must be stored in an appropriate environment and kept ready for later use. Teams may store it in databases, Data Warehouses, Data Lakes, or other systems. They can then clean, integrate, update, document, and protect it as needed.</p>
<ul>
<li><em>Example:</em> A student's name is stored in a university server database, while the calculated distance to the campus can be saved in a cloud-based analytical database. Additionally, rules are applied to avoid duplicates, incomplete data, or inconsistent formats, ensuring data quality.</li>
</ul>
</li>
<li><p><strong>Use:</strong> The organization uses the data for the purpose defined during planning. It might query or analyze the data, generate reports and dashboards, or use it to train AI models.</p>
<ul>
<li><em>Example:</em> The university uses IP addresses, response times, and virtual campus activity logs in a predictive Machine Learning algorithm to detect behavioral anomalies that indicate potential fraud. This use allows for the detection of identity theft, security issues, and the prevention of fraud in online educational activities.</li>
</ul>
</li>
<li><p><strong>Enrichment:</strong> In this phase, teams connect and transform data to add context and uncover patterns or trends that were previously hard to see. This is one way data becomes information and knowledge.</p>
<ul>
<li><em>Example:</em> A student's access log to the virtual campus can be enriched with data about the time they spent using online resources, the number of material downloads they made, their interaction counts, and their historical statistics. This gives the university more context for studying engagement and its possible relationship with academic progress, without assuming that digital activity alone explains a student's results.</li>
</ul>
</li>
<li><p><strong>Dispose:</strong> When the data is no longer needed for its original purpose, the organization decides whether to retain, archive, anonymize, or delete it. Retention policies, business needs, and legal requirements guide that decision. This phase prevents the organization from accumulating unnecessary data, which raises costs and creates extra risk when the data is personal or sensitive.</p>
<ul>
<li><em>Example:</em> When a student completes their master's program, the university may need to retain grades and other academic records for legal or administrative reasons. Some banking details or operational payment data may no longer be necessary once financial and legal obligations end. The retention policy should identify which fields to keep, delete securely, or anonymize for approved statistical use rather than preserving the full record indefinitely.</li>
</ul>
</li>
</ul>
<p>Although these phases appear in sequence, real data rarely moves through them only once. Teams may enrich it several times or integrate it with new sources, such as public APIs or partner systems. Think of the lifecycle as a continuous process whose phases can repeat whenever the need changes. It helps keep Data Management consistent, secure, and useful.</p>
<h3 id="heading-data-management-principles">Data Management Principles</h3>
<p>Now that you understand the lifecycle, you can use a few key principles to guide decisions at every stage. They give teams a shared reference instead of letting each system or department manage data in isolation.</p>
<p>The first fundamental principle already covered is considering data as an asset. From there, another relevant principle emerges: the value of data depends on its quality and context. Incorrect, incomplete, or misinterpreted data can lead to wrong decisions. For example, if a student's name contains a typo, it might not match what's stored in government databases, complicating certain processes. It's also crucial to understand that data needs metadata to be used correctly.</p>
<p>As I explained before, an isolated number like "18" has little value if it's unclear what it represents, in what unit it's expressed, how it was calculated, or when it was updated. Metadata documents this meaning and prevents ambiguities.</p>
<p>Another important principle is the need for planning. As seen in the lifecycle, the first step should be planning which data is expected to be used, among other things. In the case of enrollment, the university shouldn't collect just any student data, but only the relevant information required for the necessary processes.</p>
<p>Another essential principle is to use technology for a clear purpose. A team shouldn't choose a database or a new tool simply because it's the current popular tool. It should choose technology that addresses a real need. At the university, the decision to use a relational database, a Data Warehouse, a geographic API, or a dashboard should depend on the goal of the use case.</p>
<h3 id="heading-data-management-capabilities">Data Management Capabilities</h3>
<p>These principles become practical through a set of Data Management capabilities. The capabilities describe what an organization must be able to do with its data throughout the lifecycle.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66b716b04709012ee58fbbdc/e17edd70-c2b7-476e-ac9a-09c82c457c4e.png" alt="Data Management main capabilities. Image by author." style="display:block;margin:0 auto" width="1448" height="1086" loading="lazy">

<p>The principles guide the work, while capabilities such as Data Governance and Data Modeling put that guidance into practice. The diagram above shows the main capabilities we'll cover in the coming sections.</p>
<p>Some Data Management roles work across several capabilities. One is the <strong>Chief Data Officer (CDO)</strong>, who defines the organization's data strategy and helps ensure that teams manage data as an asset. In our use case, the CDO would help set goals for using data, such as improving attendance, enrollment, or student satisfaction.</p>
<p>Another relevant role is the <strong>Data Steward</strong>, who helps maintain data definitions, quality, and proper handling within a domain. At the university, they might verify the completeness and consistency of student location and enrollment data. A <strong>Chief Privacy Officer (CPO)</strong> may also be involved whenever a use of data affects privacy.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/-FBipS627dY" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h2 id="heading-data-governance">Data Governance</h2>
<p>Let's start with Data Governance. <a href="https://cloud.google.com/learn/what-is-data-governance"><strong>Data Governance</strong></a> defines how an organization makes decisions about data, who may access or change it, and what responsibilities come with each role. It also helps the organization meet its legal and regulatory obligations.</p>
<p>You can see why this matters at the university: admissions staff, the academic office, faculty, and even AI systems may use student data. Without clear rules, people can gain inappropriate access or make decisions without enough justification.</p>
<p>Not everyone should be able to perform every action on every piece of data. Data Governance provides an organizational control layer across the lifecycle so people use data in an orderly, secure, and legitimate way.</p>
<p>Organizations assign this work to roles such as the <strong>CDO</strong> and <strong>Data Owners</strong>. Data Owners usually work within a business area and have authority to make important decisions about the data in their domain.</p>
<p>For example, the director of mobility at a university would be the Data Owner of all data related to the transportation service offered by the university. There may also be other Data Owners like the financial director for all billing and tuition payment information.</p>
<p>Governance tools help teams control, document, and review data use. Their main purpose isn't programming or technical processing.</p>
<p>A CDO or Data Owner might use <strong>data catalogs</strong> and <strong>business glossaries</strong> to understand what information exists and what it means. They may also use policy-management platforms, dashboards, and lineage tools that track data from its source to its destination.</p>
<h3 id="heading-data-ownership">Data Ownership</h3>
<p>One of the key governance concepts is <strong>Data Ownership</strong>, which assigns responsibility for different data domains. Ownership doesn't mean that a person literally owns the data. It means that someone has the authority and accountability to make decisions about it.</p>
<p>The main role here is the Data Owner. This is usually a business leader who makes lifecycle and usage decisions for a domain rather than an end user who simply works with the data.</p>
<p>For example, the university may need a student's address to calculate the distance to campus. But the Data Owner of that domain should determine if that address can be accessed by their teachers or shared with an external transportation company, among other decisions.</p>
<p>The Data Owner usually doesn't implement the technical solution. Instead, they use tools such as <a href="https://aws.amazon.com/what-is/data-catalog/"><strong>data catalogs</strong></a> to find and understand the assets in their domain. A catalog organizes those assets through metadata and makes them easier to govern.</p>
<h3 id="heading-data-stewardship">Data Stewardship</h3>
<p>The Data Owner sets direction for a domain, while a Data Steward supports its day-to-day management. <strong>Data Stewardship</strong> includes maintaining definitions, monitoring quality, and helping ensure that data is accurate, complete, and handled according to agreed-upon standards.</p>
<p>In practice, a Data Steward might focus on verifying that students' dates and addresses are in a valid and consistent format, ensuring their names are complete, free of illegible characters, and without other issues. Also, this role emphasizes metadata to interpret data and allow other team members to do so without conflicts.</p>
<p>Data Stewards often work with <strong>data catalogs</strong> and <a href="https://docs.oracle.com/en-us/iaas/Content/data-catalog/using/enrich-business-glossary.htm"><strong>business glossaries</strong></a>. A business glossary standardizes key organizational terms. For example, it might define "distance to campus" as the route distance in meters along public streets rather than a straight-line measurement.</p>
<h3 id="heading-decision-rights">Decision Rights</h3>
<p>Another governance concept is <strong>Decision Rights</strong>: the formal definition of who can make which decisions about data in a given context.</p>
<p>Decision Rights form part of the foundation of governance. Organizations often classify decisions by their scope. Strategic decisions happen at the highest level, for example, when the university decides whether to use mobility data to offer a transportation service.</p>
<p>Then there are tactical decisions, which bridge the gap between the organization's overall strategy and day-to-day operations, such as defining eligibility criteria for candidates for the transportation service.</p>
<p>Finally, there are operational decisions, which are closest to the end users, like accepting or rejecting an enrollment application.</p>
<p>Decision Rights formally assign these choices to specific roles and data domains. The <strong>Data Owner</strong> and <strong>Data Governance Council</strong> are especially important here, with the council usually setting the broader decision framework.</p>
<p>A <strong>Data Protection Officer (DPO)</strong> may advise on a decision and escalate concerns when access would conflict with data-protection requirements. The DPO's exact authority depends on the applicable law and the organization's governance model. Teams often implement Decision Rights through workflow tools and <a href="https://www.microsoft.com/en-us/security/business/security-101/what-is-identity-access-management-iam"><strong>Identity and Access Management</strong></a> <strong>(IAM)</strong> systems that manage digital identities and permissions.</p>
<p>For example, a university administrator shouldn't have unrestricted database access. They might open a ticket in a workflow tool like <a href="https://youtu.be/GPOWZSxEslU?si=O-DG_9To79_zxttg"><strong>Jira</strong></a> to request a specific permission. The appropriate Data Owner reviews the request, and an IAM system such as <strong>Microsoft Entra ID</strong> grants the approved access to the administrator's verified identity.</p>
<h3 id="heading-data-policies">Data Policies</h3>
<p>While Decision Rights say who can make a decision, <strong>Data Policies</strong> state how people must manage and use data. They set the limits, principles, and obligations everyone must follow.</p>
<p>At the university, there might be a policy stating that user geolocation data can only be used to calculate eligibility for transportation services and not for other decisions unrelated to academic activities. This is an example of a policy related to privacy, data retention, or its use in AI models.</p>
<p>The <strong>Data Governance Council</strong> often formalizes these policies, the <strong>CDO</strong> sponsors them, and Data Stewards help teams apply them. A data catalog can publish the rules and connect them to the affected data assets, while technical systems enforce the controls.</p>
<h3 id="heading-data-standards">Data Standards</h3>
<p><strong>Data Standards</strong> are more specific than policies. A standard might define a format, naming convention, or validation rule so teams follow a policy consistently across the organization.</p>
<p>For example, the university might establish that all dates be stored in the same <a href="https://en.wikipedia.org/wiki/ISO_8601">ISO-8601</a> format or that the distance to the campus is always stored in meters. To better understand, a well-known case in computer science is the storage of decimal numbers, where the <a href="https://en.wikipedia.org/wiki/IEEE_754">IEEE-754</a> standard is commonly used for binary representation.</p>
<p>Shared standards let systems exchange data with fewer unnecessary transformations. <strong>Data Architects</strong> and <strong>Data Modelers</strong> help select and define the standards, while Data Engineers apply them in the implementation. Data Owners and Stewards oversee their use within each domain.</p>
<h3 id="heading-data-accountability">Data Accountability</h3>
<p><strong>Data Accountability</strong> means that people who have authority over data must also answer for how it's used. Teams need enough monitoring and evidence to trace important actions and understand what happened over time.</p>
<p>If a problem occurs, the organization should be able to establish who accessed the data, when they accessed it, what they did, and whether the action followed policy. Evidence, traceability, and clear responsibilities make governance demonstrable.</p>
<p>At the university, a faculty member may have a legitimate reason to access part of a student's record, but the system should log the access when appropriate. If a privacy issue arises later, audit records can help investigators understand what happened.</p>
<p>The <strong>Data Owner</strong> is accountable for proper use within the domain, while security, compliance, and platform teams provide controls such as access logs, audit trails, and lineage where relevant.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/uPsUjKLHLAg" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h2 id="heading-data-ethics">Data Ethics</h2>
<p>Governance alone isn't enough. An organization also needs to ask whether a use of data is fair, proportionate, and justifiable. That's where Data Ethics comes in.</p>
<p>In a data context, <strong>ethics</strong> applies principles such as transparency, responsibility, privacy, and non-discrimination throughout the lifecycle. This becomes especially important with personal or sensitive data because poor decisions can limit opportunities or deny people services.</p>
<p>For example, in a university, data handling during admission processes can result in discriminatory biases in many ways, some possibly unknown or unexpected. Notable among these are biases based on income, ethnicity, or disability.</p>
<p>Data can introduce these biases in numerous ways, which is why it's important to consider ethics and question whether data should be collected or used and what biases they might introduce.</p>
<h3 id="heading-ethical-data-use">Ethical Data Use</h3>
<p>Ethical data use starts with a clear, legitimate, and proportionate purpose. An organization should know why it needs each piece of data, what value it expects, and what risks the proposed use creates.</p>
<p>Laws such as the <a href="https://gdpr-info.eu/"><strong>General Data Protection Regulation</strong></a> establish legal requirements that overlap with some ethical principles, but legal compliance and ethical judgment aren't identical. The GDPR applies in the European context, and organizations must identify the rules that apply in every region where they operate.</p>
<p>An example of unethical use is when personal data from candidates entered into a form is sold to marketing companies without the candidates' explicit consent. Here, it's evident that personal data can be used to make decisions and improve a service or be used without consent for other purposes unrelated to the user's benefit.</p>
<p>Roles involved in ethical data use can include the CPO, DPO, a Chief Data Ethics Officer or ethics committee, and Data Stewards. Their exact responsibilities vary by organization. <strong>Consent management platforms (CMPs)</strong> can record and manage the permissions users grant, but consent is only one possible legal basis for processing and one part of ethical review.</p>
<h3 id="heading-consent-and-transparency">Consent and Transparency</h3>
<p>Consent and transparency are two important principles. Users should be able to understand what data is collected, why it's needed, how long it will be kept, who can access it, and whether it will be shared. These explanations should use plain language that a non-expert can follow.</p>
<p>In the case of a university, when a candidate applies for enrollment, the form shouldn't just request information and acceptance of terms. Instead, it should provide explanations about why each piece of data is requested. Clear explanations about how the data will be used and whether it will be shared with third parties should be given whenever possible.</p>
<p>Transparency doesn't end when a user submits a form. People should also be able to learn about their rights and use the processes available to request access or corrections when the applicable law provides them.</p>
<h3 id="heading-fairness-and-non-discrimination">Fairness and Non-Discrimination</h3>
<p>Fairness aims to prevent discrimination and harmful bias in the use of data. It matters especially in AI systems, where complex models and historical data can make bias difficult to detect or explain.</p>
<p>For example, a university might decide to award scholarships based on a candidate's zip code or area of residence. At first glance, this may not seem unjust, but in reality, people with very different incomes or academic records may live within the same zip code, and excluding entire areas could deprive qualified people of scholarship opportunities.</p>
<p>Data ethics requires teams to review their decision criteria. In practice, they may analyze bias, examine sensitive variables and their proxies, validate data quality and representativeness, and monitor outcomes over time. For consequential decisions, the organization should also provide suitable human oversight and a way to challenge errors.</p>
<h3 id="heading-responsible-data-sharing">Responsible Data Sharing</h3>
<p>Organizations often need to share some data with service providers because they can't deliver every part of a service alone.</p>
<p>Sharing increases risk and needs an appropriate legal basis. That basis isn't always consent. For example, the university may need to share limited data with a taxi/VTC company to provide the service, but the company shouldn't receive the student's full record.</p>
<p>Whenever the use allows it, the organization should share anonymous or <strong>pseudonymous</strong> data instead of direct identifiers. Properly anonymized data can no longer be linked to a person by reasonably likely means. Pseudonymization replaces identifiers with codes or references, but an authorized party can still reconnect the data to the person using information kept separately, so the data remains personal and protected.</p>
<h3 id="heading-ethical-risk-management">Ethical Risk Management</h3>
<p>One practical way to support ethical data use is to assess and manage risk before a new use begins. The review should consider the expected benefits alongside possible harms, bias, privacy effects, and impacts on different groups.</p>
<p>For example, when designing the enrollment application form, before including a field to collect specific data like gender, income, or any other information, it's essential for an ethics committee to evaluate their usefulness, the problems that having this data might cause for students, and whether biases or discrimination could arise.</p>
<p>Data Ethics helps the university improve its services without losing sight of the fact that the data represents real people.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/gLHMhCtxEYE" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h2 id="heading-data-security-and-privacy">Data Security and Privacy</h2>
<p>So far, we've looked at the rules and ethical choices that shape data use. We also need to protect data throughout its lifecycle. Security and privacy work together here, but they solve different problems.</p>
<p><strong>Data Security</strong> uses policies, processes, and controls to prevent unauthorized access, alteration, disclosure, or loss. <strong>Privacy</strong> focuses on whether personal data is collected and used for legitimate purposes, with appropriate transparency and respect for people's rights.</p>
<p>Security commonly aims to preserve the confidentiality, integrity, and availability of data. Only authorized people should access or change it, and it should be available when needed. Those properties alone don't guarantee privacy. An address might be strongly secured, for example, but using or selling it for an unauthorized purpose would still violate privacy.</p>
<p>The university therefore has to address security and privacy together. It handles personal data whose exposure or misuse could cause real harm to students.</p>
<p>A <strong>Chief Information Security Officer (CISO)</strong> usually leads the security strategy and coordinates technical and defensive policies. The security team uses controls such as Identity and Access Management platforms to centralize identities, authentication, and permissions.</p>
<p>On the privacy side, the previously mentioned <strong>CPO</strong> helps oversee the organization's privacy program and compliance obligations.</p>
<h3 id="heading-data-classification">Data Classification</h3>
<p>We won't cover every part of security here. But a useful starting point is to identify what information exists and classify it by sensitivity.</p>
<p>Different data can cause very different levels of harm if exposed. A common classification scheme uses <strong>public, internal use, confidential,</strong> and <strong>restricted</strong> levels.</p>
<p>The first can be accessed by anyone, while internal use data is intended for organization members, though exposure wouldn't have a particularly severe impact. In contrast, confidential data requires authorization to be accessed, and restricted data needs the highest level of protection.</p>
<p>At the university, schedules published on the website would be public. Faculty work procedures might be for internal use. A student's academic record or travel history could be confidential, while banking information or credentials could be restricted.</p>
<p>When a dataset combines several categories, the organization should classify and protect the result according to the risk of the combined data, which may be as high as or higher than its most sensitive field.</p>
<p>The organization should record the classification as metadata in the data catalog so teams can use it throughout the lifecycle. When a <strong>Data Engineer</strong> integrates a source or an analyst creates a dashboard, they can see which precautions apply. Data Stewards often help classify the data, Data Owners approve the business decision, and security and privacy teams define the required controls.</p>
<h3 id="heading-identity-and-access-management">Identity and Access Management</h3>
<p>Once data is classified, the organization must control who can access it. <strong>Identity and Access Management (IAM)</strong> covers the processes and technologies used to manage digital identities and grant, review, or revoke permissions. <strong>Authentication</strong> verifies an identity, while <strong>authorization</strong> determines what that identity may do.</p>
<p>The fundamental principle guiding data access management is the <a href="https://www.freecodecamp.org/news/principle-of-lease-privilege-meaning-cybersecurity/">principle of <strong>least privilege</strong></a>, according to which each identity receives only the permissions necessary to perform their job.</p>
<p>For example, an instructor can view the contact information of students enrolled in their courses but shouldn't access the information of unenrolled students. In other words, they have the minimum necessary permissions to perform their duties.</p>
<p>If the number of users to manage is high, it's most common to use <a href="https://www.freecodecamp.org/news/role-based-access-control-nodejs-rest-api-jwt/">Role-Based Access Control (RBAC)</a><strong>BAC)</strong>, where permissions are associated with roles like instructor, administrative staff, or student, and then each user has a specific role.</p>
<p>As for the professionals responsible for these tasks, the <strong>Data Owners</strong> decide which roles need access to the data in their domain, while the <strong>IAM administrators</strong> implement the roles and their permissions with software like Microsoft Entra ID, an IAM technology that centralizes the management of identities, groups, and access policies.</p>
<h3 id="heading-encryption">Encryption</h3>
<p>Access controls can fail, so organizations also use <a href="https://www.freecodecamp.org/news/cryptography-for-beginners-full-python-course-sha-256-aes-rsa-passwords/"><strong>cryptography</strong></a>. Data <strong>encryption</strong> transforms readable information into ciphertext that an authorized system can reverse with the correct key.</p>
<p>This encryption should be applied both at rest and in transit, meaning when data is stored and when it is transmitted from one system to another over the network.</p>
<p>For example, the university should encrypt sensitive student data at rest so stolen storage doesn't reveal it in plain text without the required keys. Communications between a student and the university server should also use TLS through HTTPS to protect data in transit. Encryption is effective only when the algorithms, implementation, and key management are sound. Examples include:</p>
<table>
<thead>
<tr>
<th>Original data</th>
<th>Protection applied</th>
<th>Protected result</th>
</tr>
</thead>
<tbody><tr>
<td><code>camille.bernard@email.com</code></td>
<td>AES-256 encryption</td>
<td><code>8A4F2C91B7E03D6A...</code></td>
</tr>
<tr>
<td><code>ES12 3456 7890 1234</code></td>
<td>AES-256 encryption</td>
<td><code>D91B70E4A62C8F15...</code></td>
</tr>
<tr>
<td><code>Password123!</code></td>
<td>Salted hashing using Argon2id</td>
<td><code>$argon2id$v=19$m=65536,t=3,p=4$...</code></td>
</tr>
</tbody></table>
<p>Common approaches use <strong>symmetric</strong> and <strong>asymmetric</strong> cryptography, and both depend on strong key management. Keys shouldn't be embedded in source code or stored unprotected beside the data they secure. A Key Management System (KMS) or Hardware Security Module (HSM) can help generate, protect, rotate, and control access to them.</p>
<p>Security Architects and security specialists help select approved encryption standards, protocols, and key-management patterns, while Data Engineers and other developers apply them in each system. Encryption doesn't solve every security problem, so teams combine it with access controls, monitoring, secure development, and usage policies.</p>
<h3 id="heading-data-masking">Data Masking</h3>
<p>Many processes don't need to reveal a complete value. <strong>Data Masking</strong> transforms or partially hides data to reduce exposure while preserving enough utility for a specific task.</p>
<p>There are mainly two forms of masking. <strong>Dynamic Data Masking</strong> partially hides the information presented to the user without altering the original stored data. Thus, an authorized person can see the full value, while someone with fewer privileges sees a partial version like <code>**1234</code>.</p>
<p>On the other hand, <strong>Persistent Data Masking</strong> creates a permanently transformed copy, allowing systems to be tested without using real data.</p>
<p>For example, if the developers of the virtual campus need to test that the application works with thousands of students, subjects, and trips, they don't need to use real data. Instead, they can replace it with fictitious data, shifting dates, changing names to fictitious ones, and so on.</p>
<p>To better understand its purpose, here are some specific examples:</p>
<table>
<thead>
<tr>
<th>Original Data</th>
<th>Technique Applied</th>
<th>Displayed Result</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td>Student’s bank account: <code>ES12 3456 7890 1234</code></td>
<td>Dynamic masking</td>
<td><code>ES** **** **** 1234</code></td>
<td>Verify the account without displaying it in full</td>
</tr>
<tr>
<td>Student’s email address: <code>lucia.garcia@email.com</code></td>
<td>Partial masking</td>
<td><code>l***@email.com</code></td>
<td>Confirm the student’s identity without exposing the full email address</td>
</tr>
<tr>
<td>Student’s full name: <code>Lucía García</code></td>
<td>Persistent substitution</td>
<td><code>Student_1048</code></td>
<td>Test systems without using real identities</td>
</tr>
<tr>
<td>Student’s home address: <code>Calle Mayor 24, Madrid</code></td>
<td>Generalization</td>
<td><code>Madrid</code></td>
<td>Analyze residential areas without knowing the exact address</td>
</tr>
<tr>
<td>Student’s date of birth: <code>18/04/2001</code></td>
<td>Age-range generalization</td>
<td><code>20–25 years old</code></td>
<td>Analyze age groups without revealing the exact date of birth</td>
</tr>
<tr>
<td>Internal student identifier: <code>STU-45821</code></td>
<td>Pseudonymization</td>
<td><code>9F3A-71BC</code></td>
<td>Manage a trip without sharing the student’s full identity</td>
</tr>
</tbody></table>
<p>Masking, pseudonymization, and anonymization overlap in some implementations, but they aren't interchangeable. Masking alone doesn't guarantee that a dataset is anonymous. <strong>Pseudonymization</strong> replaces identifiers with codes while keeping the information needed to reconnect those codes to people separately. Because re-identification remains possible, pseudonymized data is still personal data and needs protection. Anonymization requires reducing identification risk to the point that people are no longer identifiable by reasonably likely means.</p>
<p>In this case, <strong>Data Stewards</strong> determine which data should be concealed and why, while security and <strong>Data Engineering</strong> teams implement these decisions at a low level.</p>
<h3 id="heading-privacy-controls">Privacy Controls</h3>
<p>The previous techniques help prevent unauthorized access. <strong>Privacy Controls</strong> address a different question: whether the organization has a valid purpose and appropriate rules for processing personal data.</p>
<p>The principles of <strong>Privacy by Design</strong> and <strong>Privacy by Default</strong> make privacy part of a system from the start and set privacy-protective defaults. One fundamental control is <strong>data minimization</strong>, which means collecting only what the stated purpose requires. An enrollment form, for example, shouldn't request a complete medical history unless a specific service and lawful purpose justify it.</p>
<p>Other controls apply to the purpose of the data and its retention. So in use cases, students' personal data shouldn't be kept longer than necessary or reused for other purposes like personalized marketing campaigns without authorization.</p>
<p>In Europe, the <strong>GDPR</strong> establishes principles and requirements that guide these controls. The organization must also identify the rules that apply in every region where it operates. The <strong>DPO</strong> monitors and advises on compliance where that role applies, while Data Owners, privacy specialists, security teams, and system designers turn the requirements into practical controls.</p>
<h3 id="heading-audit-and-compliance">Audit and Compliance</h3>
<p>The organization must be able to show that its controls and policies work. <strong>Auditing</strong> independently reviews the available evidence and tests whether controls operate as expected. <strong>Compliance</strong> covers the ongoing work of meeting internal policies, standards, contractual duties, and applicable regulations.</p>
<p><strong>Logs</strong> are one important source of audit evidence. They can record who accessed data, when, from which system, and what action they took. Teams protect these records against tampering and retain them for a defined period based on risk, legal needs, and cost. <strong>Security Information and Event Management (SIEM)</strong> platforms centralize events from different systems and can generate alerts for unusual behavior.</p>
<p>For example, if a teacher occasionally checks the record of a student enrolled in their course, the behavior may be legitimate. But if they download hundreds of student records with whom they have no connection during the night and from another country, an alert should be generated for the security team to investigate the incident.</p>
<p>An audit might analyze logs, test whether identities have excessive privileges, and review how teams apply encryption and other controls. Independent reviewers and separation of duties help prevent the same administrator from controlling a system and the evidence used to assess their actions.</p>
<p>Roles involved include the <strong>CISO</strong>, the <strong>DPO</strong>, the <strong>Data Owners</strong>, the <strong>Data Stewards</strong>, and the compliance and audit teams. In summary, security and privacy require knowing what data exists, limiting who can use it, protecting it through controls, and preserving evidence that all of this is correctly followed.</p>
<h3 id="heading-security-operations-secops">Security Operations (SecOps)</h3>
<p>Data security is ongoing work. Beyond policies and encryption mechanisms, <strong>SecOps (Security Operations)</strong> brings people, processes, and technology together for continuous defense.</p>
<p>SecOps teams monitor systems, detect threats, investigate alerts, and respond to incidents. They try to reduce risk early while staying ready to contain and recover from events that still occur.</p>
<p>In the university context, the SecOps team is responsible for overseeing the digital ecosystem in real time. For example, if a SIEM generates an alert because a teacher has downloaded hundreds of academic records at night or engages in any similar suspicious activity, the SecOps analyst receives the notification, assesses the risk, and takes action, such as temporarily blocking access as a preventive measure.</p>
<p>SecOps teams may also coordinate vulnerability scanning and remediation for the virtual campus and other systems so weaknesses are addressed before attackers exploit them.</p>
<p>In SecOps, key roles include <strong>SecOps engineers</strong> and <strong>security analysts</strong>, who work with the CISO to define and implement a defense strategy. These professionals rely on SIEM platforms to centralize event information and <strong>SOAR (Security Orchestration, Automation, and Response)</strong> tools to automate responses to common threats.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/UpkqXK0B2E0" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h2 id="heading-data-architecture">Data Architecture</h2>
<p>Once you know who makes decisions about data and how to protect it, you still need to organize the systems that store, move, and process it.</p>
<p><a href="https://aws.amazon.com/what-is/data-architecture/"><strong>Data Architecture</strong></a> designs the structure that meets those needs. Once the organization defines what it wants to achieve with data, the architecture shows how systems will store, transport, protect, and analyze it.</p>
<p>This capability connects business goals with technical implementation. It goes beyond choosing a database or sketching a pipeline: the design identifies which data the organization needs, where it lives, how it relates, and how it moves. The work can produce data models, flow diagrams, standards, and other architecture decisions.</p>
<p>In this use case, a candidate might enter their home address in a web form during enrollment. This data could then be sent to an admissions system and used in a query to a geographic API to calculate the distance to the campus, for example. It could also be used along with other data present in other systems, like class schedules, to verify eligibility if transportation service is requested.</p>
<p>Here, Data Architecture is responsible for designing how this complete data journey is carried out.</p>
<p>A poorly designed <a href="https://youtu.be/2Xf0ACFGdQk?si=-U4GMqTK51mZaM8M">architecture</a> can fail in several ways. Systems may exchange data incorrectly or stop communicating, interrupting a service for users. Even if nothing breaks outright, teams may duplicate data unnecessarily, raising costs and making integration harder. Good architecture reduces these risks and makes tradeoffs explicit.</p>
<p>The <strong>Enterprise Data Architect</strong> maintains the organization-wide view, while <strong>Data Architects</strong> and <strong>Solution Architects</strong> adapt it to particular solutions. <strong>Data Modelers, Data Engineers, Data Stewards, Data Owners</strong>, and security specialists contribute the design details and help put the architecture into practice.</p>
<p>In simple terms, architects design and document the solution, engineers and developers implement it, and Data Owners and Data Stewards clarify the meaning, rules, and responsibilities of the data.</p>
<h3 id="heading-enterprise-data-architecture">Enterprise Data Architecture</h3>
<p>The broadest level of data architecture is <strong>Enterprise Data Architecture</strong>, the organization-wide view of how data should be organized, connected, and governed.</p>
<p>At a university, Enterprise Architecture provides a comprehensive view of how systems should be coordinated, what each should do, and how information is exchanged between them.</p>
<p>For example, the web application through which a candidate completes a process must be properly connected with an admissions system or a database where that information is stored. This database or system can also support the operation of other internal systems dedicated to analyzing that data, or parts of it, according to privacy policies.</p>
<p>This work is led by the Enterprise Data Architect with support from the CDO, who aligns the architecture with the data strategy, and other roles like Application Architects or Security Architects. Additionally, Data Owners validate that the architecture meets the needs of their domains.</p>
<h3 id="heading-data-domains">Data Domains</h3>
<p>Data domains are an important part of an organization's architecture. Not all data describes the same part of the business, so teams group related concepts to make the data easier to organize, understand, and govern.</p>
<p>A <strong>Data Domain</strong> is a logical area containing related organizational concepts and data. A university might define domains for students, faculty, finance, and mobility. Grouping data this way makes its meaning clearer and helps the organization assign a Data Owner to each domain.</p>
<p>Additionally, a domain isn't isolated from others, as data often needs to be contextualized, even if it belongs to different domains. For example, the transportation service may require data from the mobility domain, as well as the schedule of its courses present in another domain.</p>
<p>Each governed domain should have a Data Owner with suitable decision authority. A Data Architect helps design the domain boundaries and relationships, which teams can represent in a conceptual model and document in a <strong>data catalog</strong>.</p>
<h3 id="heading-data-flows">Data Flows</h3>
<p>Once the domains and systems are clear, the team designs how data moves between them. <strong>Data Flows</strong> document the source, the systems and processes involved, the transformations applied, and the final storage or consumption point.</p>
<p>You can describe a flow at several levels. A high-level diagram may show data moving from one domain to another. An implementation view names the systems involved, while a more detailed design can show the fields, interfaces, and transformations that each consumer requires.</p>
<p>In the process of enrolling a candidate at the university, the main flow could be as follows:</p>
<ol>
<li><p>The candidate accesses the enrollment portal and completes the form with their personal, academic, and contact information.</p>
</li>
<li><p>The enrollment portal validates the required fields and data format. Then, it sends the application to the admissions system via an API.</p>
</li>
<li><p>The admissions system creates the candidate's file and stores documents like the ID, academic degree, and certificates in a document database.</p>
</li>
<li><p>When the application is approved, the admissions system generates an offer that the candidate views and accepts through the enrollment portal.</p>
</li>
<li><p>The portal consults the academic management system to display courses, schedules, and available slots, allowing the candidate to select their options and confirm enrollment.</p>
</li>
<li><p>The payment system sends the transaction to an external payment gateway. The gateway returns the payment status, such as authorized, rejected, or pending. The university stores only a reference to the transaction and its result.</p>
</li>
<li><p>If the payment is successful, the academic management system creates the final enrollment and converts the candidate's file into a student file.</p>
</li>
<li><p>Next, the system updates the identity platform, virtual campus, and billing system. The student receives their credentials, payment receipt, and enrollment confirmation.</p>
</li>
<li><p>Finally, the necessary data can be pseudonymized and sent via a data pipeline to an analytics platform, where statistics on applications, admissions, payments, and enrollments are calculated and displayed on a dashboard.</p>
</li>
</ol>
<p>Some data movements need near-real-time responses, especially in the transportation service, while others can run later in a batch. The flow should state those timing requirements.</p>
<p>The main role that designs the flow and determines which components participate is the Data Architect, while the Data Engineer implements it. But Security Architects also participate, reviewing data protection during the flow, and Data Owners authorize exchanges between domains. Finally, it's important to highlight the significance of <strong>data lineage</strong> tools for maintaining, monitoring, and auditing the flows.</p>
<h3 id="heading-operational-data-architecture">Operational Data Architecture</h3>
<p>The systems in an architecture serve different purposes. It's useful to distinguish between systems that run day-to-day processes and systems designed mainly for analysis.</p>
<p>The first group forms the <strong>Operational Data Architecture</strong>. This area covers the systems that keep an organization running each day. <a href="https://www.databricks.com/blog/what-is-oltp"><strong>Online Transactional Processing</strong></a> <strong>(OLTP)</strong> systems handle frequent operational transactions and use controls that help preserve data integrity and consistency.</p>
<p>The university's operational architecture could include the virtual campus, application services, and a database. The portal would normally use an application or service layer rather than giving the user's browser direct database access. These components support the daily capture and management of data rather than long-running historical analysis.</p>
<p>That is, the operational database can serve as an authorized source to know the current status of enrollments, for example. However, it is not the most suitable place to continuously run complex queries over several years of activity to build statistics, as they could consume the resources needed for daily operations. Therefore, the data required to study trends, compare programs, or create dashboards is handled in another part of the architecture explained later.</p>
<p>For this type of information, it's common to use relational databases like PostgreSQL or MySQL. But you should choose the specific technology based on the volume of your operations, expected availability, existing infrastructure, and other requirements such as maximum response latency.</p>
<p>A <strong>Solution Architect</strong> or <strong>Data Architect</strong> designs the operational architecture, <strong>Software Engineers</strong> build the application components, and <strong>Data Engineers</strong> help define and implement the data exchanges between them.</p>
<h3 id="heading-analytical-data-architecture">Analytical Data Architecture</h3>
<p>While operational architecture handles day-to-day activity, <a href="https://youtu.be/ivSPZB6zUKY?si=IpdpBvmZ3pPbOs38"><strong>Analytical Data Architecture</strong></a> supports the integration, aggregation, and study of historical data. Its systems help teams create reports, discover patterns, and prepare data for AI models without placing unnecessary analytical load on operational services.</p>
<p>At a university, this architecture would be used to combine data on schedules, attendance, and budgets so an analyst can calculate the monthly expenses per master's program or the variation in student attendance over different periods. Similarly, a Data Scientist could use historical data to estimate future demand for transportation services, for example.</p>
<p>A typical analytical flow uses <a href="https://en.wikipedia.org/wiki/Extract,_transform,_load"><strong>ETL</strong> or <strong>ELT</strong></a> (which we'll discuss more below) to obtain data from several sources. Teams then transform it before or after loading it into a specialized system such as a Data Warehouse. The result gives Business Intelligence tools and Machine Learning workflows suitable data without competing directly with the virtual campus for the same operational resources.</p>
<p>In this area, the Data Architect or <strong>Analytics Architect</strong> designs the analytical components of an architecture. Meanwhile, <strong>Analytics Engineers</strong> and Data Engineers design the processes that prepare data for analysis by <strong>Data Analysts</strong> or <strong>Data Scientists</strong>.</p>
<h3 id="heading-cloud-and-hybrid-data-architectures">Cloud and Hybrid Data Architectures</h3>
<p>Architecture also determines where components run: in the cloud, on premises, or across both. <strong>Cloud Data Architecture</strong> uses cloud computing, storage, database, and analytics services. These services can simplify scaling and reduce the need to manage physical hardware, but the organization still has to configure security, control costs, and govern its data.</p>
<p>On the other hand, a <strong>Hybrid Data Architecture</strong> combines on-premises systems with cloud services. This approach is common when an organization retains existing applications in its own data center but wants to use the cloud's elasticity or analytical services.</p>
<p>To understand the motivation for a hybrid architecture, in the case of the university, the academic system and the database with records and payments might initially remain in internal infrastructure to prevent third-party access to those data. But some pseudonymized data could be sent to cloud analytics platforms to obtain certain statistics on virtual campus usage or academic metrics.</p>
<p>Nevertheless, keeping certain data on-premises doesn't automatically guarantee greater security, just as using the cloud doesn't automatically mean a loss of control. The decision should consider data sensitivity, latency, availability, scalability, and the total cost of each solution.</p>
<p>In this design, the <strong>Enterprise Data Architect</strong> and the <strong>Data Architect</strong> participate, along with the <strong>Cloud Architect</strong>, who specializes in understanding cloud services to use them correctly in an architecture.</p>
<p><strong>Network Engineers</strong>, <strong>Cloud Engineers</strong>, and Data Engineers also participate in its implementation, while the DPO and Data Owners must review issues like which data can leave the internal infrastructure and for what purpose.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/SYPrzij9G04" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h2 id="heading-data-modeling-and-design">Data Modeling and Design</h2>
<p>Data architecture defines which systems manage data and how they exchange it. <a href="https://www.databricks.com/blog/what-is-data-modeling"><strong>Data Modeling</strong></a> <strong>and Design</strong> specifies how those systems represent the information. It identifies the concepts that matter to the organization and describes their attributes, relationships, and rules.</p>
<p>A data model is a simplified representation of part of reality. It gives people a shared structure they can understand and later implement. Before creating the university's database, for example, the team needs to define what a candidate, student, master's program, and enrollment mean, which information each one needs, and how they relate.</p>
<p>Teams commonly describe a design at three levels:</p>
<ol>
<li><p>a <strong>conceptual model</strong> with the main business concepts,</p>
</li>
<li><p>a <strong>logical model</strong> that adds detail without depending on a particular technology,</p>
</li>
<li><p>and a <strong>physical model</strong> that maps the design to structures in a specific platform.</p>
</li>
</ol>
<p>Each model can evolve as the team learns more about the requirements.</p>
<p>The <strong>Data Modeler</strong> leads the design and works with the <strong>Data Architect</strong> to fit it into the wider architecture. Data Owners, Data Stewards, Business Analysts, and domain experts clarify meaning and rules. <strong>Database Administrators (DBAs)</strong>, Data Engineers, and Software Engineers contribute to the physical design and implementation.</p>
<h3 id="heading-conceptual-data-models">Conceptual Data Models</h3>
<p>A <strong>conceptual data model</strong> gives you a high-level view of an organization's data. It shows the main business concepts and their relationships without technical details about storage or format.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66b716b04709012ee58fbbdc/8921466f-eab4-4cdf-8f9d-a0c225033138.png" alt="Example of conceptual data model. Image by author." style="display:block;margin:0 auto" width="1448" height="1086" loading="lazy">

<p>For example, as shown in the diagram above, in a university, a conceptual model would include concepts like candidate, student, course, or enrollment. (Keep in mind that this is a sketch to help you better understand the concept of a conceptual model, not a diagram used in production.)</p>
<p>At this level, it's sufficient to indicate what each of these concepts is and what they can do in relation to others, such as a student requesting enrollment or an enrollment containing a set of courses. The goal is for both technical teams and academic leaders to understand the same reality before designing a specific solution.</p>
<p>This model is usually developed through interviews or workshops with Data Owners, Data Stewards, Business Analysts, and domain experts, who are generally not very technical given the nature of the task. In this process, the <strong>Data Modeler</strong> or <strong>Data Architect</strong> creates diagrams with the model and validates that the concepts match the business glossary.</p>
<h3 id="heading-logical-data-models">Logical Data Models</h3>
<p>A logical model develops the conceptual model in more detail while remaining independent of a specific technology. It defines entities, attributes, identifiers, relationships, cardinalities, and other business constraints.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66b716b04709012ee58fbbdc/9da1318c-2d68-4140-92f0-b4bfb6123ddc.png" alt="Example of logical data model. Image by author." style="display:block;margin:0 auto" width="1535" height="1024" loading="lazy">

<p>For example, the Student entity might have attributes like ID, name, email, and address. A student can enroll in several courses, and a course can have many students. This <strong>many-to-many</strong> relationship could be represented at the logical level with an intermediate entity called Enrollment, which might include attributes like date, status, or academic year.</p>
<p>People often associate logical models with relational databases, but a logical model doesn't have to use that paradigm. Think of it as a technology-independent specification of the information and its connections, even though different paradigms represent entities and relationships in different ways.</p>
<p>These relational models can be refined. For example, in a relational database, its logical model can be normalized to reduce duplications and incorrect dependencies. But in other paradigms or solutions, there will be very different procedures. And the design of this model is led by a <strong>Data Modeler</strong>, in collaboration with a <strong>Data Architect</strong>, as mentioned earlier.</p>
<h3 id="heading-physical-data-models">Physical Data Models</h3>
<p>The physical data model maps the logical design to a specific technology. In a relational database, for example, it turns logical entities and relationships into tables, columns, keys, constraints, partitions, and <a href="https://youtu.be/W_v05d_2RTo?si=RY4KGH-lHWGGKnZ_"><strong>lower-level structures</strong></a> such as indexes, which often use a <a href="https://youtu.be/K1a2Bk8NrYQ?si=G0a3Ij3sFStSiU84"><strong>B-tree</strong></a>.</p>
<p>At the university, student records could live in a relational table. The DBMS decides how to store the table itself, while the team can create indexes, often B-tree indexes, on selected columns to speed up common queries.</p>
<p>As you can imagine, the same logical model can generate different physical models. For instance, the academic system could be implemented in PostgreSQL or MySQL. So the physical design must consider the DBMS intended for use, data volume, query patterns, security, availability, and operational cost to provide an effective solution.</p>
<p>In this design phase, the <strong>Data Modeler</strong> or <strong>Database Designer</strong>, the Data Architect, and the Data Engineers primarily work together with the Software Engineers to implement the solution.</p>
<h3 id="heading-entity-relationship-modeling">Entity-Relationship Modeling</h3>
<p>Entity-relationship diagrams are a common way to represent relational concepts. Depending on how much detail they contain, they can support conceptual or logical modeling. <strong>Entities</strong> are typically shown as rectangles, while lines represent relationships, <strong>cardinality</strong>, and optionality.</p>
<p>For example, a student can have many enrollments, and each enrollment belongs to a single student. In contrast, a relationship between Student and Course would be many-to-many because a student can be enrolled in many courses at once.</p>
<p>Keys are also identified to distinguish each instance of an entity and maintain the integrity of their relationships, among other details that aren't as relevant here.</p>
<p>If you're curious, you can read more about database design <a href="https://www.freecodecamp.org/news/how-to-design-structured-database-systems-using-sql-full-book/">in my previous book here</a>.</p>
<h3 id="heading-dimensional-modeling">Dimensional Modeling</h3>
<p>Another useful approach, especially in Data Warehouses and analytical systems, is <a href="https://www.ibm.com/docs/en/informix-servers/14.10.0?topic=model-concepts-dimensional-data-modeling"><strong>dimensional models</strong></a>. These models organize data around facts and dimensions. <strong>Facts</strong> record measurable events, while <strong>dimensions</strong> provide the context used to analyze them.</p>
<p>For example, in a transportation service, you might have a fact table called Trip, containing a row for each completed journey, recording measures such as cost, distance, and duration. But instead of storing the traveler's data in the same table, it relates to others representing dimensions like Student, Date, or Transportation Provider. Thus, the fact table models the existence of trips, while other dimensional tables contain specific data for each trip, such as the person or transportation provider, resulting in a structure known as a <a href="https://www.databricks.com/blog/what-is-star-schema"><strong>star schema</strong></a>.</p>
<p>This type of model is primarily used because it simplifies analytical queries and allows studying the same fact from different "perspectives." For example, the university could calculate the total cost of trips by month, student, or provider without having to construct excessively complex queries.</p>
<h3 id="heading-data-model-governance">Data Model Governance</h3>
<p>Data models also need governance so they stay consistent, current, and aligned with the implementation. Teams should maintain the connection between conceptual, logical, and physical designs as each one changes.</p>
<p>Once teams approve a model, the implementation should follow it or update it through a controlled change. Unexpected differences between an expected and an actual schema are commonly called <strong>schema drift</strong>.</p>
<p>For example, the university's model might define a numeric age field while the implementation stores it as text. That difference may look small, but downstream systems can fail if they rely on the agreed type. Teams should detect and control schema changes so models, contracts, and implementations stay aligned.</p>
<p>A <strong>Data Governance Council</strong> or <strong>Architecture Review Board</strong> may review significant model changes. Data Owners confirm that the design reflects business rules, while database and engineering teams implement approved changes through a controlled process.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/LXK58eRNo9Q" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h2 id="heading-data-storage-and-operations">Data Storage and Operations</h2>
<p>Data models guide the implementation of systems that store data persistently and make it available to applications and other systems. The team has to choose an appropriate storage technology, keep the data accessible when needed, and operate the system at an acceptable level of performance.</p>
<p><a href="https://www.ibm.com/think/topics/data-storage"><strong>Data Storage</strong></a> <strong>and Operations</strong> covers the design, implementation, and operation of storage systems throughout their lifecycle. This includes choosing databases, file systems, and object stores, then maintaining, monitoring, and optimizing them. As you'll see, a database isn't the right home for every type of data.</p>
<p>Data architecture determines which systems the organization needs and how they communicate. Data modeling specifies how they represent information. Data Storage and Operations turns those designs into working storage systems. A physical model might say that the Student entity maps to a PostgreSQL table with a B-tree index on <code>student_id</code>. This section focuses on implementing and operating that kind of design.</p>
<p>The main objectives of data storage are to maintain availability, integrity, and ensure good performance of the underlying system. To achieve these, you shouldn't always use one technology for all the data in an organization, as the data for an enrollment or a class video, for example, has very different structures, uses, and requirements. So the same organization often combines different storage systems.</p>
<table>
<thead>
<tr>
<th>Need</th>
<th>Example data</th>
<th>Most common system</th>
<th>Example technologies</th>
</tr>
</thead>
<tbody><tr>
<td>Record the current state of operations</td>
<td>Students, enrollments, payments, and transportation requests</td>
<td>Operational database</td>
<td>PostgreSQL, MySQL, SQL Server, Oracle Database, or MongoDB</td>
</tr>
<tr>
<td>Store large documents and content</td>
<td>Academic certificates, supporting documents, materials, and videos</td>
<td>File Storage or Object Storage</td>
<td>NFS, SMB, Amazon S3, Azure Blob Storage, Google Cloud Storage, or MinIO</td>
</tr>
<tr>
<td>Analyze integrated and historical information</td>
<td>Monthly travel costs and attendance trends</td>
<td>Data Warehouse</td>
<td>Snowflake, BigQuery, Amazon Redshift, Azure Synapse Analytics, or Teradata</td>
</tr>
<tr>
<td>Store data for advanced analytics</td>
<td>Original provider files, events, and virtual campus logs</td>
<td>Data Lake or Lakehouse</td>
<td>Object Storage, Parquet, Delta Lake, Apache Iceberg, Spark, or Trino</td>
</tr>
</tbody></table>
<p>The team should choose the technology based on its expected volume, access patterns, sensitivity, availability, cost, and other requirements. Every additional technology increases operational complexity, so each one should solve a real problem.</p>
<p>A <strong>Database Administrator</strong> creates, configures, secures, tunes, and maintains databases. <strong>Storage Administrators</strong> manage the underlying storage, while <strong>Site Reliability Engineers</strong> and platform teams monitor services and respond to reliability incidents. The exact division of work depends on the platform and organization.</p>
<h3 id="heading-databases">Databases</h3>
<p>A database is an organized collection of data that applications can store, change, and query. A <a href="https://neo4j.com/blog/graph-database/what-is-database-management-system/"><strong>Database Management System</strong></a> <strong>(DBMS)</strong> is the software that manages databases and provides services for querying, concurrency, security, recovery, and administration. PostgreSQL is a DBMS. The university's academic database would be a particular database managed by a PostgreSQL server or service.</p>
<p>Operational systems often need <strong>transactional</strong> support, especially for workflows such as enrollment and payment. A transaction groups related operations into one logical unit. The <a href="https://youtu.be/GAe5oB742dw?si=Sg_nxUQBRLIhFp1g"><strong>ACID properties</strong></a> <strong>(Atomicity, Consistency, Isolation, and Durability)</strong> describe guarantees that help applications preserve valid state despite failures and concurrent access.</p>
<p>For example, when making a payment, values must be modified in multiple places corresponding to the users exchanging money. Thus, the atomicity of a transaction allows confirming all these modifications together, and if any fail, reverting them to maintain the previous state.</p>
<p>Database designs make different tradeoffs among data model, scale, consistency, latency, and access patterns. That's why several database <strong>paradigms</strong> exist:</p>
<table>
<thead>
<tr>
<th>Paradigm</th>
<th>Characteristics</th>
<th>Use case example</th>
<th>Technologies</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Relational</strong></td>
<td>Organizes data into related tables, uses predefined schemas, and supports keys, constraints, and transactions</td>
<td>Managing students, courses, enrollments, invoices, and transportation requests, where relationships and integrity are important</td>
<td>PostgreSQL, MySQL, SQL Server, or Oracle Database</td>
</tr>
<tr>
<td><strong>Document-oriented</strong></td>
<td>Groups information into documents, usually similar to JSON, which may contain nested structures and evolve more flexibly</td>
<td>Storing forms from multiple providers when they don't all submit exactly the same fields</td>
<td>MongoDB or Couchbase</td>
</tr>
<tr>
<td><strong>Key-value</strong></td>
<td>Retrieves a value through a unique key and prioritizes simple, fast access patterns</td>
<td>Maintaining portal sessions, temporary results, or a cache of frequent queries</td>
<td>Redis or Amazon DynamoDB</td>
</tr>
<tr>
<td><strong>Graph-oriented</strong></td>
<td>Represents data through nodes and relationships, enabling complex connections to be traversed efficiently</td>
<td>Analyzing relationships among students, courses, lecturers, transportation routes, or dependencies between services</td>
<td>Neo4j, Amazon Neptune, or ArangoDB</td>
</tr>
</tbody></table>
<p>These are only a few database paradigms. A university could use PostgreSQL for an academic system that manages Student, Enrollment, and Course records through tables and relationships. For a specialized route or network analysis, a <a href="https://neo4j.com/docs/getting-started/graph-database/"><strong>graph-oriented database</strong></a> could represent locations as nodes and connections as edges. The operational taxi service itself might still use a relational or other transactional store, depending on its access patterns.</p>
<p>The <strong>Data Architect</strong> and <strong>Data Modeler</strong> select the database paradigm and design with input from the engineers who will build and operate the solution.</p>
<p>Once operational, the database is maintained by a <strong>Database Administrator</strong>. Before this, a <strong>Database Engineer</strong> will have implemented the physical model, created instances, schemas, tables, and other necessary elements to subsequently operate the environment. <strong>Software Engineers</strong> develop the applications that access these databases and perform queries.</p>
<h3 id="heading-file-and-object-storage">File and Object Storage</h3>
<p>Not all data fits naturally in a database. Universities manage diplomas, identity documents, and large files such as class recordings. A DBMS can store binary content, but file or object storage often provides more suitable access, scale, and cost characteristics for these assets.</p>
<p><strong>File Storage</strong> organizes files into directories and exposes them through paths and protocols such as <a href="https://learn.microsoft.com/en-us/windows-server/storage/nfs/nfs-overview"><strong>NFS</strong></a> or <a href="https://en.wikipedia.org/wiki/Server_Message_Block"><strong>SMB</strong></a>. Teams can implement it with a Network Attached Storage (NAS) system or a cloud service such as Amazon EFS or Azure Files.</p>
<p><a href="https://cloud.google.com/learn/what-is-object-storage"><strong>Object Storage</strong></a> stores content as objects with identifiers and metadata, usually inside buckets or containers. Its namespace and access model differ from a mounted hierarchical file system, even when tools display folder-like prefixes. Services such as Amazon S3, Azure Blob Storage, and Google Cloud Storage can hold large collections of documents, images, and videos.</p>
<p>The main difference is the access model. File Storage behaves like a shared file system, while applications usually access Object Storage through an API using an object key and metadata.</p>
<p>For example, the university could use <a href="https://www.ibm.com/think/topics/file-storage">File Storage</a> to save administrative documents for each student, like registrations and certificates, in a shared folder. This way, authorized staff could manage them as if they were in a traditional file system.</p>
<p>On the other hand, it could use Object Storage to store a large number of class recordings, images, and multimedia materials in a bucket. Instead of locating a video by navigating folders, the system could retrieve it directly using its identifier or by filtering through its metadata.</p>
<p>The roles responsible for configuring and operating these systems are mainly <strong>Storage Administrators</strong>, <strong>Cloud Engineers</strong>, and <strong>Platform Engineers</strong>, while <strong>Software Engineers</strong> implement access to these systems from other applications.</p>
<h3 id="heading-data-warehouses">Data Warehouses</h3>
<p>Operational databases are usually optimized for current transactions and application queries rather than repeated analysis across years of integrated history. Complex analytical workloads can also compete with the applications using the same resources. Organizations therefore often copy suitable data into a separate <a href="https://youtu.be/k4tK2ttdSDg?si=_YRRhtlEBhAW_jAx"><strong>Data Warehouse</strong></a>.</p>
<p>A Data Warehouse is an analytical repository that integrates data from multiple sources and organizes it for repeatable analysis, reports, and dashboards. These systems support <a href="https://aws.amazon.com/what-is/olap/"><strong>Online Analytical Processing</strong></a> <strong>(OLAP)</strong> workloads that scan and aggregate many records, in contrast with the <strong>Online Transactional Processing (OLTP)</strong> workloads common in operational applications.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/iw-5kFzIdgY" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<p>This difference often affects storage design. Many Data Warehouses use columnar storage because an analytical query may scan a few columns across a large number of rows. To calculate the total cost of taxi rides by date, for example, the engine may only need the cost and date columns.</p>
<p>Many operational relational databases use row-oriented storage because it efficiently retrieves or changes complete records. These are common patterns rather than universal rules. Specific products can support several storage formats.</p>
<p>In practice, the university could have a database and a pipeline where data is periodically extracted to be inserted into a Data Warehouse. There, a dimensional data model could be applied as seen earlier to analyze the data and allow an analyst to answer questions like:</p>
<ul>
<li><p>What's the average monthly cost of a certain course per student?</p>
</li>
<li><p>How has in-person attendance changed over a specific period?</p>
</li>
<li><p>How many students enrolled last month?</p>
</li>
</ul>
<p>It's important to understand that a Data Warehouse doesn't replace a database. Rather, it's an auxiliary system focused on data analysis. Among the technologies available for these types of systems are cloud platforms like Snowflake, Google BigQuery, or Amazon Redshift.</p>
<p>The roles that work with them include <strong>Data Architects</strong> or <strong>Analytics Architects</strong>, who design the analytical platform, while Data Engineers design the pipelines to extract and load the data.</p>
<p><strong>Data Warehouse Administrators</strong> or Platform Engineers manage performance, permissions, reliability, and cost. Data Analysts and Business Intelligence professionals query the governed analytical data without changing the operational source records.</p>
<h3 id="heading-data-lakes-and-lakehouses">Data Lakes and Lakehouses</h3>
<p>A traditional Data Warehouse applies defined schemas and organizes data for known or anticipated analytical needs.</p>
<p>But this isn't always the case, as an organization might also need to retain original files, semi-structured data, logs, images, or events whose future use isn't yet fully defined.</p>
<p>For these situations, we can use a <a href="https://youtu.be/-bSkREem8dM?si=dCvdno6pKghx3nQx"><strong>Data Lake</strong></a>, which is a repository designed to store large amounts of data in their original formats or with minimal transformations.</p>
<p>A Data Lake also supports analytical and data-processing needs, but it can retain structured, semi-structured, and unstructured data with fewer transformations at ingestion. It's often associated with <a href="https://www.dremio.com/wiki/schema-on-read-vs-schema-on-write/"><strong>schema-on-read</strong></a>, where a query or processing job applies part of the structure, while a traditional Data Warehouse commonly uses <strong>schema-on-write</strong> before loading curated data.</p>
<p>Schema-on-read doesn't remove the need for metadata, security, quality, and governance. Without them, the lake can become a <a href="https://www.dremio.com/wiki/data-swamp/"><strong>data swamp</strong></a>.</p>
<p>To understand how information is organized in a Data Lake, in the university's use case, the data could be processed in layers according to their readiness for consumption.</p>
<ol>
<li><p>In a specific area of the system, data could be kept in their original formats without modification, such as CSV or JSON files. This would allow for reprocessing the information if an error in a transformation is detected later or if another type of analysis is needed.</p>
</li>
<li><p>In another area, the data could be in a different format, or the same format but with certain transformations applied to remove invalid records or standardize units of measure, for example.</p>
</li>
<li><p>In a curated area, teams could apply further quality checks and transformations until the data meets the requirements for dashboards, with selected statistics pre-calculated.</p>
</li>
</ol>
<p>This separation doesn't imply that all original data is always retained indefinitely, as privacy, security, and retention policies must be followed.</p>
<p>For example, the university may temporarily store documents submitted by a candidate during the admission process. But if the candidate is rejected and enough time has passed, the university must delete those documents, even if derived and anonymized data have been generated to compile statistics on the admission process.</p>
<p><a href="https://youtu.be/PQFWQmL3fLY?si=uTQmSYzMMbXZidcH"><strong>Lakehouses</strong></a> add capabilities such as transactions, schema enforcement, and table management to the flexible storage commonly used for a Data Lake. They can let several analytical workloads share one data foundation, although they don't eliminate every reason to use specialized systems.</p>
<p>Among the technologies used to build a Lakehouse are Delta Lake, Apache Iceberg, and Apache Hudi. They define the data format usually stored on services like Amazon S3, Azure Blob Storage, or Google Cloud Storage and processed using tools like Apache Spark, Databricks, or Trino.</p>
<p>In the case of the university, a Lakehouse could be used to store student data, enrollments, attendance, and taxi rides in one place. This way, the university could securely update this data and use it directly to create reports, such as monthly transportation expenses or the number of students attending classes, without needing separate systems.</p>
<p>Finally, those responsible for designing and implementing data ingestion from different sources in these systems are the <strong>Data Engineers</strong>. On the other hand, <strong>Platform Engineers</strong> manage the infrastructure, and <strong>Analytics Engineers</strong>, along with Data Scientists, consume the data to conduct relevant analyses and research.</p>
<h3 id="heading-backup-and-recovery">Backup and Recovery</h3>
<p>Even a well-designed storage system can suffer hardware failures, software defects, corruption, mistakes, or attacks that cause data loss. That's why <strong>Backup and Recovery</strong> is essential in production.</p>
<p>A <strong>backup</strong> is a recoverable copy of data kept for loss or corruption scenarios. A backup is useful only if the organization protects it, verifies it, and tests the recovery process. Common mechanisms include:</p>
<ul>
<li><p><strong>Full backup:</strong> Copies the entire dataset. For example, the university could perform a complete weekly copy of the enrollment database. It simplifies restoration, though it requires more time and storage.</p>
</li>
<li><p><strong>Incremental backup:</strong> Saves only the changes made since a previous copy. After a monthly full backup, only the modified enrollments could be copied daily. It reduces volume, but recovery may require several linked copies.</p>
</li>
<li><p><strong>Snapshot:</strong> Captures the state of a storage system at a point in time. Depending on the technology, it may share underlying storage and may not be an independent copy. The university could take one before a major academic-system change, while still keeping separate backups for stronger protection.</p>
</li>
<li><p><strong>Log backup:</strong> A backup that relies on a change log, allowing recovery of the database to a previous point in time if data is accidentally deleted. It's more precise but requires maintaining the entire log sequence.</p>
</li>
<li><p><strong>Replication:</strong> Maintains a replica of an entire system that can take over if the main system fails. For example, a secondary database could continue serving the enrollment portal, improving availability. But it can also replicate deletions or errors, so it doesn't replace a backup.</p>
</li>
</ul>
<p>A recovery strategy uses two common objectives. The <strong>Recovery Point Objective (RPO)</strong> expresses the maximum tolerable data loss in time, while the <strong>Recovery Time Objective (RTO)</strong> states how long service restoration may take before the impact becomes unacceptable.</p>
<p>For example, the university might hypothetically set an RPO of five minutes and an RTO of one hour for the enrollment database during the registration period. This would mean that, in the event of a serious failure, they aim to lose a maximum of five minutes of operations and restore service within an hour. In contrast, a collection of already published videos might allow for a slower recovery if durable copies exist elsewhere.</p>
<p>A well-known practice in designing backup solutions is the <strong>3-2-1 rule</strong>, which involves maintaining three copies of important information, using at least two storage media or technologies, and keeping one copy offsite. But you should tailor your solution to the requirements of your organization.</p>
<p>The Data Owners and business leaders are responsible for identifying critical processes and determining acceptable loss or interruption. On a technical level, a <strong>DBA</strong> implements and validates the database recovery mechanisms. Additionally, <strong>Storage Administrators</strong> and <strong>Cloud or Platform Engineers</strong> manage storage and automate backups, while <strong>Site Reliability Engineers</strong> monitor and conduct tests to ensure recovery functions as expected.</p>
<h3 id="heading-retention-and-archiving">Retention and Archiving</h3>
<p>An organization shouldn't keep every piece of data indefinitely. Doing so raises costs, complicates discovery, and increases the impact of a breach.</p>
<p>A <strong>retention policy</strong> should state how long data stays active, when it moves to an archive, and when it is deleted or anonymized. The policy should reflect business needs, contractual duties, legal requirements, and applicable holds.</p>
<p>In this context, it's important to distinguish between two concepts:</p>
<ul>
<li><p><strong>Archive:</strong> Stores information that's no longer regularly used but must remain accessible. For example, a former student's record might be moved to an archive with lower storage and retrieval costs, in case it's needed to verify their existence when requesting a certificate.</p>
</li>
<li><p><strong>Retention:</strong> Defines how long data is kept and what happens when that period ends. For example, the personal and academic documentation of a rejected applicant might be retained until the admission process and the appeal period are over. Afterward, those documents would be deleted, although the university might keep anonymous statistics on the number of applications received.</p>
</li>
</ul>
<p>Data Owners, Records Managers, legal counsel, and privacy specialists help establish retention periods. A <a href="https://en.wikipedia.org/wiki/Legal_hold"><strong>legal hold</strong></a> can temporarily suspend normal disposal for information related to an investigation or proceeding. The organization therefore needs a documented reason to keep or delete data rather than deciding only by whether it seems useful.</p>
<p>Afterward, Data Stewards classify the data, and DBAs, Storage Administrators, or Cloud Engineers implement the policies. As an interesting technology, <strong>Write Once Read Many (WORM)</strong> storage is often used for records that must remain unalterable.</p>
<h3 id="heading-performance-and-availability">Performance and Availability</h3>
<p>Stored and protected information must be available when the service needs it and perform within its agreed targets. <strong>Performance</strong> describes qualities such as response time and throughput, while <strong>availability</strong> measures whether the expected service can be used.</p>
<p>A system can be technically running yet unusable if it responds too slowly. It can also be fast when online but fail its availability target because of frequent outages. Teams need to manage both qualities.</p>
<p>Some techniques that can improve the performance of a storage system include:</p>
<ul>
<li><p>Create <strong>indexes</strong> on frequently queried fields, after ensuring they justify the space cost of the index itself.</p>
</li>
<li><p>Analyze the most frequent queries or workloads to try to optimize the query plans generated by the DBMS.</p>
</li>
<li><p>Introduce <strong>caches</strong> whenever possible, especially when results will be needed multiple times.</p>
</li>
</ul>
<p>Performance work depends on the system and workload. Adding hardware won't fix every problem, as software design matters just as much. Unnecessary pipeline transformations, for instance, increase execution time and cost even when they don't cause an outage.</p>
<p>On the other hand, <strong>redundancy</strong> is often used to improve availability. Essentially, if there are replicas of the same server or system, it's less likely that all will fail simultaneously, leaving end users without service.</p>
<p>You can manage the existence of replicas with <a href="https://www.geeksforgeeks.org/system-design/failover-mechanisms-in-system-design/"><strong>failover mechanisms</strong></a>, so if a PostgreSQL instance, for example, stops working, you can redirect traffic to another replica automatically and transparently for the end user.</p>
<p>In the university example, during the last days of the enrollment period, thousands of students might access the portal simultaneously. To maintain good performance, requests would be distributed among several servers, preventing any single one from becoming overloaded and reducing wait times. Also, the database could have replicas so that if one instance fails, another can automatically take over.</p>
<p>This way, the system would remain fast during high demand and stay available even in the event of an unexpected failure.</p>
<p>To measure an organization's performance and availability objectives, <a href="https://www.freecodecamp.org/news/observability-in-cloud-native-applications/">observability</a> is especially important. This involves generating metrics, logs, and statistics, and managing them with tools like <strong>Prometheus</strong> and <strong>Grafana</strong> to monitor the system and check its availability and performance at any given time.</p>
<p>This analysis and optimization of a storage system is usually performed by the <strong>DBA</strong>, although certain <strong>Software Engineers</strong> and <strong>Data Engineers</strong> may also be involved, optimizing the data pipelines through which various systems exchange information. Regarding availability, <strong>SREs</strong>, Platform Engineers, and Cloud Engineers automate deployments, monitoring, scaling, and implement failover mechanisms.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/t1HzlKKvJcA" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h2 id="heading-document-and-content-management">Document and Content Management</h2>
<p>So far, we've worked with several kinds of data: structured records in tables, <a href="https://youtu.be/bcvt22A_G9Y?si=J3ziItPt5mRCoN5W"><strong>semi-structured data</strong></a> such as JSON, and unstructured content such as scans, images, videos, and free-form text.</p>
<p><strong>Documents</strong> can contain a mix of structured metadata and unstructured content, so they need their own management practices.</p>
<p>A document usually doesn't follow a rigid row-and-column structure, but it can still have metadata such as a title, author, type, date, or tags. Some digital formats also contain an internal hierarchy. A JSON document, for example, uses named fields and nested objects:</p>
<pre><code class="language-json">{
  "student_id": "ALU-2026-8942",
  "full_name": "Amélie Dubois",
  "master_program": "Master in Artificial Intelligence",
  "campus_distance_km": 18.2,
  "rideshare_benefit_approved": true,
  "last_trip": {
    "date": "2026-03-09",
    "cost_euros": 24.50
  }
}
</code></pre>
<p>Many digital files combine content with descriptive metadata such as a title, author, or creation date. That metadata makes the content easier to identify, organize, secure, and retrieve. <strong>Document and Content Management</strong> provides the processes and systems for doing this consistently.</p>
<p>Simply placing files in folders isn't enough at organizational scale. Teams need ways to classify documents, describe their content, control access, track versions and retention, and find them later. A basic file system or database can be part of the solution, but a document or content platform adds the management features the organization needs.</p>
<p>For example, the journey of a document in the university systems might be:</p>
<ol>
<li><p>The candidate's academic record is captured from a form, an email, or any equivalent means.</p>
</li>
<li><p>It's indexed and metadata is added to provide context.</p>
</li>
<li><p>It's stored in an appropriate repository.</p>
</li>
<li><p>Authorized users and systems can access or share it under the applicable controls. For example, an admissions analyst might query approved extracted fields to count candidates with prior study in a subject area without opening every certificate manually.</p>
</li>
<li><p>Finally, it's deleted or retained according to applicable policies.</p>
</li>
</ol>
<h3 id="heading-unstructured-data">Unstructured Data</h3>
<p>An important part of the data managed by an organization contains <a href="https://www.salesforce.com/eu/data/what-is-unstructured-data/"><strong>unstructured information</strong></a>. This means that, as mentioned above, its content isn't rigidly structured in clearly identifiable and directly queryable fields. For example, a motivation letter in PDF, a scanned image of a diploma, or a contract may contain information that's difficult to structure.</p>
<p>Documents may have format-specific metadata such as a title or creation date. This helps identify the file but rarely describes everything inside it. The body may contain free-form text, images, tables, or other content that the system must extract or index before it can answer detailed queries.</p>
<p>To perform queries on this information, the system indexes this content or applies techniques like <a href="https://cloud.google.com/use-cases/ocr"><strong>Optical Character Recognition</strong></a> <strong>(OCR)</strong>, Natural Language Processing, or Intelligent Document Processing.</p>
<p>For example, if the university wants to know how many candidates have taken math-related courses before entering the master's program, it must first extract that information from academic certificates, normalize it, and store it in queryable fields. When extracting data from a document, you should maintain a link to the original document to verify its source later.</p>
<p>After extracting useful content, the system can <strong>index</strong> it in a structure optimized for search. The index may represent a document with fields or <strong>key-value pairs</strong> such as the candidate identifier, document type, courses taken, and subject area.</p>
<pre><code class="language-json">{
  "index_id": "idx_cert_2026_0042",
  "student_id": "ALU-2026-8942",
  "student_name": "Amélie Dubois",
  "document_type": "Academic Transcript",
  "extracted_subjects": [
    {
      "original_name": "Algèbre Linéaire",
      "normalized_area": "Mathematics",
      "score": "18/20"
    },
    {
      "original_name": "Introduction à Python",
      "normalized_area": "Computer Science",
      "score": "16/20"
    }
  ],
  "metadata": {
    "issuing_country": "France",
    "language": "fr",
    "confidence_score_ocr": 0.98
  },
  "original_file_url": "https://s3.uni.edu/bucket-cert/2026/8942_transcript.pdf"
}
</code></pre>
<p>For example, above you can see what an indexed document might look like. Originally, it could be an academic certificate of a candidate, but for the system, it's a JSON dictionary with this information, meaning the internal content of the document is organized hierarchically.</p>
<p>Representing it this way makes it much easier to perform queries, as you can navigate and access fields like <strong>score</strong> to see each candidate's grades in the various subjects they've taken at another university.</p>
<h3 id="heading-document-capture">Document Capture</h3>
<p>The first operational step is <strong>Document Capture</strong>, the controlled process for accepting a document into the organization's systems.</p>
<p>In these processes, it's important to consider the format of the document to be captured, as they're not always digital files. Often, they can be physical documents delivered to an administrative body, which then needs to digitize and upload them to the system.</p>
<p>In any case, assuming a digitized document reaches the data management systems, an adequate capture should perform at least the following actions:</p>
<ul>
<li><p>Validate the file format and size, and ensure it doesn't contain malicious software.</p>
</li>
<li><p>Assign it an identifier and basic metadata, such as its origin and date of receipt, along with a digital fingerprint like a hash to detect changes in the file.</p>
</li>
<li><p>Preserve the original and, when necessary, extract a usable representation of its content.</p>
</li>
</ul>
<p>If a document is scanned, its text appears as pixels rather than directly searchable characters. OCR converts visible text into machine-readable text. More advanced <a href="https://aws.amazon.com/what-is/intelligent-document-processing/"><strong>Intelligent Document Processing</strong></a> <strong>(IDP)</strong> systems can also classify documents and extract fields, tables, and layout using rules and Machine Learning models.</p>
<p>For example, a candidate might upload a photo of a diploma issued in another language from their phone. The capture process would detect the language, extract all the corresponding text using OCR, and associate the file with their application so that the document's content can later be reviewed, knowing to whom it belongs.</p>
<h3 id="heading-document-classification">Document Classification</h3>
<p>After capture, the system may need to classify the document so it knows what it is and which workflow, access rules, and retention policy apply. People can do this manually, or software can assist with rules and Machine Learning.</p>
<p>In some workflows, the university may let users attach certificates, reports, and other supporting files. The system can't trust the filename or assume that every upload is safe. It must validate the file, scan it according to security policy, and identify the document type before further processing.</p>
<p>A filename alone isn't reliable: <code>A.pdf</code> could contain almost anything. Classification assigns one of the organization's defined document types and determines the next processing steps. Teams may automate low-risk cases and route uncertain or consequential cases to a person for review.</p>
<h3 id="heading-content-storage">Content Storage</h3>
<p>After capture and classification, the organization stores the original document and its metadata. Object or file storage often holds the binary file, while a document database such as MongoDB, Couchbase, or Amazon DocumentDB may hold flexible metadata or extracted content. The right combination depends on access, retention, search, and scale requirements.</p>
<p>Other alternatives include using a <strong>Document Management System (DMS)</strong> or a platform with <strong>Enterprise Content Management (ECM)</strong> capabilities. These document repositories are based on File or Object Storage internally, with additional capabilities that a bucket or folder alone cannot provide, such as advanced metadata management. Lastly, it's worth mentioning the existence of <strong>Content Management Systems (CMS)</strong>, which are designed for creating and publishing content on websites.</p>
<h3 id="heading-search-and-retrieval">Search and Retrieval</h3>
<p>A document is useful only if authorized users and systems can find it when needed. After storage and indexing, the platform may support several search methods:</p>
<ul>
<li><p><strong>Metadata search:</strong> Filters by fields such as <code>document_type = Academic Certificate</code>.</p>
</li>
<li><p><strong>Full-text search:</strong> Finds words or phrases in extracted text and ranks the matching documents.</p>
</li>
<li><p><strong>Semantic search:</strong> Retrieves documents by meaning, even when they don't contain the exact words in the query.</p>
</li>
</ul>
<p>For example, an authorized employee could search for a certain teacher's employment contract using keywords like "contract" or the person's name, even if they don't remember the exact file name. Alternatively, with a semantic search like the one we can perform on Google, they can also locate that document or any other based on the meaning of its content.</p>
<h3 id="heading-records-management">Records Management</h3>
<p>Not every document has the same value or lifecycle. Teams may discard drafts quickly, while official evidence of an activity or decision must be preserved as a <strong>record</strong>. <strong>Records Management</strong> controls those records throughout their required lifecycle.</p>
<p>Unlike a draft, a record is an official document that must be preserved and kept authentic, complete, and protected. For example, a draft of an admission offer would be disposable, while the accepted and signed offer by the student becomes a record.</p>
<p>Each type of record has an associated <strong>retention period</strong> that determines how long it must be kept and what should be done afterward. If there's an investigation or legal proceeding, a <strong>legal hold</strong> may be applied, temporarily suspending its disposal. At a university, official course records or final academic transcripts might be considered records.</p>
<p>Overall, the most common technologies and roles in document management can be summarized as:</p>
<table>
<thead>
<tr>
<th>Document Phase</th>
<th>Key Technologies</th>
<th>Roles</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Capture</strong></td>
<td>Azure AI Document Intelligence, Google Document AI, Amazon Textract, Tesseract OCR</td>
<td><strong>Software and Integration Engineers</strong> implement the capture pipeline, while <strong>ML Engineers</strong> design the data extraction models.</td>
</tr>
<tr>
<td><strong>Storage</strong></td>
<td>OpenText Content Management, MongoDB</td>
<td><strong>Information Architects</strong> design the logical content structure, while <strong>Platform Engineers and ECM/DMS Admins</strong> implement and operate the storage systems.</td>
</tr>
<tr>
<td><strong>Indexing and Search</strong></td>
<td>Elasticsearch, OpenSearch, Apache Solr</td>
<td><strong>Information Architects</strong> design the indexing strategy, while <strong>Search and Software Engineers</strong> implement the search engines and queries.</td>
</tr>
<tr>
<td><strong>Retention and Maintenance</strong></td>
<td>Microsoft Purview Records Management, Amazon S3 Object Lock</td>
<td><strong>Records Managers, Data Owners, and the DPO</strong> define the policies, rules, and compliance requirements, while <strong>Security and Compliance Teams</strong> implement security mechanisms and conduct audits.</td>
</tr>
</tbody></table>
<h2 id="heading-reference-and-master-data-management">Reference and Master Data Management</h2>
<p>Organizations reuse some data across many processes and systems. The same student may appear in the admissions platform, virtual campus, and billing platform. If each system represents that person differently, duplicates and contradictions quickly appear.</p>
<p><strong>Reference and Master Data Management</strong> coordinates this shared data so systems can use consistent, trusted values.</p>
<p>First, you need to distinguish between:</p>
<ul>
<li><p><strong>Master Data:</strong> This describes an entity that is relevant and shared by several processes. For example, the record of the student <code>Amélie Dubois</code>.</p>
</li>
<li><p><strong>Reference Data:</strong> These are allowed values within a classification or organization of the master data. For example, <code>APPROVED</code> can represent the status of an accepted enrollment application, with the candidate's record considered master data.</p>
</li>
</ul>
<p>The goal isn't to force every piece of data into one database. It's to identify trusted values and systems of record, define who maintains them, and distribute the right representation to each consumer.</p>
<h3 id="heading-master-data">Master Data</h3>
<p><a href="https://youtu.be/l83bkKJh1wM?si=-9sCSxMXkAbnQwjj"><strong>Master Data</strong></a> represents core entities such as people, organizations, places, or products. At a university, it might include students, faculty, and courses. A trusted student record could contain a global identifier, name, and selected contact attributes, while sensitive payment details remain in the systems that need them.</p>
<p>But payment information won't be used in all processes involving these data. This is why authorized data needs to be distributed to each system so that the entire organization has a consistent view of the data, even if it's used differently.</p>
<p>Not all attributes of a record have to come from the same place. A payment platform may maintain its fiscal information, while the student portal keeps the most recent contact email. Then, a <strong>Master Data Management (MDM)</strong> platform would integrate these sources to provide a reliable view to other systems.</p>
<p>Platforms used for this purpose include Reltio, SAP Master Data Governance, and IBM InfoSphere MDM. The role that operates them is the <strong>MDM or Data Architect</strong>, who defines the data model and the architecture used for deployment, while the <strong>MDM Engineer</strong> configures the platform. Data Engineers and Integration Engineers need to be aware of these authorized sources of truth.</p>
<h3 id="heading-reference-data">Reference Data</h3>
<p><strong>Reference Data</strong> supplies controlled values used to classify or organize other data. The university might allow a transportation request to have the status <code>PENDING</code>, <code>APPROVED</code>, or <code>REJECTED</code>. If applications use different terms for the same state, integration and reporting become unreliable. These approved status values are Reference Data.</p>
<p>These values usually change infrequently but aren't immutable. This can happen because new values need to be added to the classification, like <code>CANCELLED</code>.</p>
<p>To make this modification, a <strong>Data Steward</strong> would document its meaning, while the <strong>Data Owner</strong> of the corresponding data domain approves the change. Subsequently, the <strong>Integration Engineers</strong> are responsible for distributing the new value to the systems that consume it.</p>
<h3 id="heading-golden-records">Golden Records</h3>
<p>Information about one entity often appears in several systems, with each system storing what it needs. An MDM platform can combine selected trusted attributes into a unified view called a <strong>Golden Record</strong>. The goal is a governed, useful representation, not a copy of every piece of information the organization holds.</p>
<p>For example, the university might have an admissions system where a student's personal data, like the name <code>Amelie Dubois</code>, is stored, while their payment information is in a system specialized for processing payments. After verifying they belong to the correct person, they can be linked to provide a single view of the student.</p>
<p>A Golden Record isn't automatically perfect or permanently definitive. It's the best trusted view available under the current matching and survivorship rules.</p>
<h3 id="heading-entity-resolution">Entity Resolution</h3>
<p>To build that view, the platform must decide which records refer to the same real-world entity. This task is called <strong>Entity Resolution</strong>.</p>
<p>For example, records named <code>Amélie Dubois</code> and <code>A. Dubois</code> might refer to the same person, or to different people. A resolution process can compare authorized attributes such as email, phone number, or date of birth and apply deterministic rules or probabilistic matching. Because false matches and missed matches can cause harm, teams should review uncertain cases and provide a way to correct decisions.</p>
<p>This is assigned to the <strong>MDM Engineer</strong>, while the <strong>Data Quality Analyst</strong> analyzes and supervises the results along with a <strong>Data Steward</strong>. It's implemented through the functionalities incorporated in MDM platforms, services like AWS Entity Resolution, or record linkage libraries like Splink.</p>
<h3 id="heading-deduplication">Deduplication</h3>
<p>Another issue that drives the need for Entity Resolution is the presence of duplicate data. For example, a candidate might register on the virtual campus with one email and later apply for admission using another. If it's confirmed that both records belong to the same person, they should be handled appropriately in each specific scenario.</p>
<p>This process is called <strong>Deduplication</strong> and involves using Entity Resolution to detect and manage repeated records, aiming to prevent them from being treated as independent entities. Common approaches include linking, which retains the records in their original systems and creates a correspondence between their identifiers. Alternatively, merging generates a consolidated record, similar to the Golden Record.</p>
<p>Here, responsibilities are divided among several roles. The <strong>Data Owner</strong> sets the criteria guiding the Deduplication process, the <strong>MDM Engineer</strong> implements these criteria on the platform, and the <strong>Data Quality Analyst</strong>, along with the <strong>Data Steward</strong>, supervises the outcome of the process.</p>
<h3 id="heading-survivorship-rules">Survivorship Rules</h3>
<p>When several source records refer to the same entity, the MDM process must decide which value to use for each attribute in the Golden Record.</p>
<p>Previously, we saw this with the example of the student name <code>Amélie Dubois</code> and <code>A. Dubois</code>, values that may appear in several records. Thus, when creating a Golden Record, it will be necessary to decide which one to keep.</p>
<p>For this, there are <strong>Survivorship Rules</strong>, which, as their name suggests, are rules that determine the resolution of these situations based on the data involved.</p>
<p>These criteria are designed by a <strong>Data Owner</strong>, while a <strong>Data Steward</strong> supervises the process and its application, and an <strong>MDM Engineer</strong> implements these rules on a platform.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/SkZCQ6KZfi0" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h2 id="heading-metadata-management">Metadata Management</h2>
<p>In the previous section, document metadata helped identify us a file and describe details such as its type or creation date. But metadata applies far beyond documents.</p>
<p>Metadata is data that describes other data. The number 42 is ambiguous by itself. A column name such as <code>age</code>, a unit, a definition, and a timestamp can tell you what it represents and how to interpret it.</p>
<p>At organizational scale, metadata needs deliberate management of its own. <strong>Metadata Management</strong> collects, connects, maintains, and publishes metadata so people and systems can find and use data correctly.</p>
<p>The goal is to make data understandable and support governance, quality, security, and discovery. People create some metadata manually, while scanners and integrations can collect technical or operational metadata from systems and files. A <strong>metadata repository</strong> connects these descriptions, and a <strong>data catalog</strong> makes them available to users.</p>
<p>The <strong>CDO</strong> and <strong>Data Governance Council</strong> can set the metadata strategy and governance model. A <strong>Metadata Manager</strong> or <strong>Metadata Engineer</strong> operates the platform, while Data Owners and Data Stewards maintain definitions, ownership, and other domain metadata.</p>
<h3 id="heading-business-metadata">Business Metadata</h3>
<p>Metadata includes more than column names and file properties. <strong>Business Metadata</strong> explains data in the language and rules of the organization.</p>
<p>It includes documented definitions, business rules, ownership, and usage constraints. The university might define an <strong>"Enrolled Student"</strong> as a student with at least one active course enrollment, then specify what "active" means. That definition is business metadata.</p>
<p>The knowledge used to generate the definition is provided by a <strong>Business Analyst</strong>, who, together with a <strong>Data Steward</strong>, turns it into a clear and consistent definition.</p>
<h3 id="heading-technical-metadata">Technical Metadata</h3>
<p><strong>Technical Metadata</strong> describes how systems represent data and where it's located. It includes schemas, data types, table and column names, paths, file formats, keys, and interfaces.</p>
<p>For example, in a catalog, it might indicate that a student's address data is located in a certain table attribute, is textual, and doesn't allow null values. All this information is considered metadata because it describes where the data is and how it's represented.</p>
<p>At this level, <strong>Data Architects</strong> or <strong>Data Modelers</strong> typically define the data representation so that Data Engineers, Analytics Engineers, and Database Administrators can handle its implementation.</p>
<h3 id="heading-operational-metadata">Operational Metadata</h3>
<p><strong>Operational Metadata</strong> records what happens when systems process or use data. It can include job start and end times, row counts, query activity, freshness, status, and failures.</p>
<p>For example, at the university, it might be recorded that the enrollment request pipeline ran at <code>6:00 AM</code>, processed <code>543</code> students, and completed successfully in 20 seconds.</p>
<p>This metadata is often obtained from orchestrators like Apache Airflow, application logs, and cloud platforms, which are operated by <strong>Data Engineers</strong> and <strong>DataOps</strong> or platform professionals who monitor these executions.</p>
<h3 id="heading-data-catalogs">Data Catalogs</h3>
<p>A <a href="https://youtu.be/guw5a6mJwqI?si=g9VVHmpJ-nC3L_Rf"><strong>Data Catalog</strong></a> is one of the main systems used to bring these metadata types together.</p>
<p>A Data Catalog is a searchable inventory of the organization's data assets. It usually stores metadata and references to source systems rather than copying all the underlying data. Its main purpose is discovery and understanding, although some catalogs also support access-request and governance workflows.</p>
<p>For example, if an analyst is looking for enrollment records from the past 6 months, the catalog should indicate which database or storage system holds that information, who's responsible for it, other metadata like the name of the system or table where it is located, and the access rules.</p>
<p>Among the most well-known commercial solutions are Collibra, Alation, and Microsoft Purview, often deployed on cloud ecosystems like AWS Glue Data Catalog and Google Cloud Knowledge Catalog. Management is handled by the <strong>Metadata Manager</strong> or <strong>Metadata Engineer</strong>, who administers this platform.</p>
<h3 id="heading-business-glossaries">Business Glossaries</h3>
<p>A <a href="https://youtu.be/6BYXcApCCzg?si=U6_5PXcFsdXSoZVy"><strong>Business Glossary</strong></a> is a controlled vocabulary that establishes the official meaning of the organization's concepts. It shouldn't be confused with a <strong>data dictionary</strong>: the dictionary describes tables and columns of a specific system, while the glossary defines business concepts that may be implemented in many systems.</p>
<p>For example, the term <em>Completed Trip</em> might mean a trip that has reached its destination and whose billing has been validated. This definition prevents the mobility area from considering a trip complete when the journey ends, while finance only does so when the invoice is received. The term should include its definition, synonyms, rules, related concepts, owner, steward, and approval status.</p>
<p>A business expert or Business Analyst proposes the term, the <strong>Data Steward</strong> reviews its clarity and potential conflicts, and the <strong>Data Owner</strong> approves its use. The glossary can start as a simple document, but as it grows, you should manage it within the data catalog to link each term with its columns, rules, reports, and policies.</p>
<h3 id="heading-data-lineage">Data Lineage</h3>
<p><a href="https://cloud.google.com/discover/what-is-data-lineage"><strong>Data Lineage</strong></a> describes where data came from, how it moved, which transformations changed it, and where it's consumed.</p>
<p>At the university, lineage could show that an address enters through an application, passes to a geographic API, produces a route distance, and contributes to a mobility-eligibility decision. A separate operational flow may then share only the minimum trip details with the transportation provider. This metadata helps teams assess the impact of changes, investigate errors, and demonstrate how a result was produced.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66b716b04709012ee58fbbdc/315b3639-d1dd-4ffb-9e30-53f355820ac8.png" alt="Example of data lineage in the use case. Image by author." style="display:block;margin:0 auto" width="1672" height="941" loading="lazy">

<p><strong>Data Engineers</strong>, <strong>Analytics Engineers</strong>, and <strong>Metadata Engineers</strong> help capture lineage through tools such as dbt, OpenLineage, or Apache Atlas. Automation can collect lineage from supported systems and generate visual paths from sources to dashboards, but teams still need to validate gaps, semantics, and manually implemented processes.</p>
<h3 id="heading-metadata-standards">Metadata Standards</h3>
<p>Metadata also needs standards, quality controls, and governance. <strong>Metadata Standards</strong> define how teams document, represent, and exchange it.</p>
<p>The goal is to help people and systems locate, understand, integrate, and exchange data consistently. ISO-8601 is a data representation standard for dates and times. Within an organization, <strong>snake_case</strong> might be a metadata naming convention, while a defined JSON schema could standardize how a tool exchanges metadata.</p>
<p>Among the most notable external standards are the <a href="https://en.wikipedia.org/wiki/ISO/IEC_11179"><strong>ISO/IEC 11179</strong></a> family, used in metadata registries, and the <a href="https://www.dublincore.org/"><strong>Dublin Core</strong></a> for describing all types of digital resources. The responsibility for applying these standards falls on the <strong>Data Architect</strong> and the <strong>Metadata Manager</strong>, who select the standards.</p>
<h3 id="heading-metadata-quality">Metadata Quality</h3>
<p>Like other data, metadata should meet defined criteria for accuracy, completeness, consistency, and freshness.</p>
<p>Poor metadata can undermine governance and processing because users may interpret otherwise correct data incorrectly. If a catalog says that distance is measured in kilometers while a system stores meters, for example, downstream calculations can be wrong.</p>
<p>Teams can measure metadata quality through checks for completeness, validity, consistency, and freshness. Lineage then helps them see which downstream assets a bad definition or missing field could affect. A <strong>Metadata Manager</strong>, Data Steward, and Data Quality Analyst may share this work.</p>
<h3 id="heading-metadata-governance">Metadata Governance</h3>
<p><strong>Metadata Governance</strong> defines who can create, approve, change, and retire metadata. Metadata has its own lifecycle, and a controlled process keeps definitions from changing in production without the right review.</p>
<p>For example, if a data analyst proposes changing the description of the concept <strong>"distance to campus"</strong> to specify that it will now be measured in meters instead of kilometers, they can't modify that definition directly. Governance requires that this proposal first go through the Data Steward to ensure the new wording is clear and consistent with the rest of the glossary, and then be validated by the corresponding Data Owner.</p>
<p>Only after this approval process is the metadata officially updated in production, preventing uncontrolled changes from causing unnecessary failures.</p>
<p>Although the responsibility usually falls on the <strong>Data Steward</strong> and the <strong>Data Owners</strong>, this assignment isn't universal. At the executive level, the CDO and the Data Governance Council establish the general policies that guide how governance should be conducted in the organization.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/KkC1Bj3Kt5k" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h2 id="heading-data-integration-and-interoperability">Data Integration and Interoperability</h2>
<p>Most organizations don't keep all their data in one system. They use several systems for different jobs, so those systems need reliable ways to exchange and combine information.</p>
<p><a href="https://youtu.be/65bgnTD_xj4?si=UGx7vp3RlaIvdvgq"><strong>Data Integration and Interoperability</strong></a> addresses that need. <strong>Interoperability</strong> means systems can exchange data and interpret it consistently, while integration combines or connects data for a particular use.</p>
<p>Because each system holds only part of the picture, data <a href="https://cloud.google.com/learn/what-is-data-integration"><strong>integration</strong></a> gathers or virtually connects information from different sources to provide the view a consumer needs.</p>
<p>The goal is to make the right data available in the right place, format, and time. One requirement is <strong>latency</strong>: the delay between data being created or requested and becoming available to the consumer. The portal may need current taxi availability within seconds, while a monthly cost dashboard can refresh overnight. Integration must also be secure, observable, and auditable.</p>
<p>For example, university systems must agree on the meaning and unit of "distance to campus" or declare a reliable conversion. Without that shared contract, a value in kilometers can be mistaken for meters and cause serious errors.</p>
<p>Once interoperability is ensured, the data can be integrated to generate, for example, dashboards. At the university, data can be obtained from different systems, such as a database with transportation service records and a payment platform, to ultimately generate a dashboard that shows statistics of the cost of that service over a period of time.</p>
<p><strong>Data Architects</strong> define interoperability principles and shared patterns. <strong>Data Engineers</strong> and <strong>Integration Engineers</strong> design and build ingestion, mappings, and exchanges. Platform Engineering, DataOps, and SRE teams help deploy, monitor, and recover the supporting services.</p>
<h3 id="heading-data-ingestion">Data Ingestion</h3>
<p><strong>Data Ingestion</strong> moves data from a source into a target environment for storage or processing. The target may keep the data temporarily or persistently.</p>
<p>Sources can include databases, APIs, files, applications, and event streams. Destinations can include operational systems, queues, Data Warehouses, Data Lakes, and other platforms. In a <strong>push</strong> pattern, the source sends data, while in a <strong>pull</strong> pattern, the destination or connector requests it.</p>
<p>It's also important to mention that there's a distinction in different types of integration depending on whether the data is inserted into a system or queried "directly" from its sources.</p>
<p>One type is <strong>physical integration</strong>, where data is extracted and stored in a common destination using ETL or ELT processes. For example, the university could load travel and payment records into a Data Warehouse every night using Apache Airflow, Apache Spark, or Azure Data Factory to later generate a cost dashboard.</p>
<p>On the other hand, <strong>virtual integration</strong> allows querying different sources without having to store their information in a destination environment, as if the sources formed a single system for querying. In this way, the university could combine the travel database and the payment platform in a single query using technologies like Denodo, obtaining integrated data.</p>
<p>Virtual integration doesn't normally persist a separate consolidated copy, although query engines may cache or process data temporarily. Ingestion, by contrast, deliberately moves data into another environment, where further transformations may follow.</p>
<p>For example, the university might want to analyze whether the free taxi service is actually improving attendance at in-person classes. To do this, it <strong>integrates</strong> data from sources that record travel logs and student attendance, which are likely in different systems. In this process, the sources are queried, and the data is ingested into a Data Warehouse where it's analyzed.</p>
<p>Technologies used for ingestion include Apache NiFi and Kafka Connect, as well as tools like AWS Database Migration Service or Azure Data Factory. The choice depends on the source, destination, volume, frequency, security, and interoperability requirements. A <strong>Data Engineer</strong> usually designs and implements the ingestion process with the relevant source and platform teams.</p>
<h3 id="heading-batch-integration">Batch Integration</h3>
<p>After defining the sources and destination, the team decides when ingestion and processing should run. The answer depends on how fresh the consumer needs the data to be.</p>
<p>In <strong>Batch Integration</strong>, the system collects and processes groups of records on a schedule or trigger. This approach is often simpler and more cost-efficient when consumers don't need real-time results, although teams still need to manage the concentrated load that a batch can place on source and destination systems.</p>
<p>For example, the university might load completed trips and payments into a Data Warehouse each night to update the transportation service cost dashboard. The process would extract the data, temporarily store it in a staging area, apply the necessary transformations, and load it into the destination. If the frequency is somewhat higher, the batches are called <strong>micro-batches</strong>, as they contain less data, though the process is exactly the same.</p>
<p>This type of integration is implemented with technologies like Apache Airflow, Apache Spark, AWS Glue, or Azure Data Factory, primarily used by <strong>Data Engineers</strong>. Additionally, the integration's operation is supervised and monitored by <strong>DataOps or Platform Engineering</strong> professionals.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/IELMSD2kdmk" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h3 id="heading-streaming-integration">Streaming Integration</h3>
<p>When consumers need lower latency, <strong>Streaming Integration</strong> processes events continuously or soon after sources produce them. Instead of waiting for a large scheduled batch, producers publish events that enter ingestion and processing as they arrive.</p>
<p>For example, a transportation company might publish real-time events indicating that a trip has been requested, accepted, started, completed, or canceled, allowing the student portal to be updated immediately.</p>
<p>These events are typically distributed through platforms like Apache Kafka, Apache Pulsar, or Amazon Kinesis, while Apache Flink or Spark Structured Streaming enable filtering, transforming, aggregating, and finally integrating them.</p>
<p>Here, the most important role remains the Data Engineer, although the more specialized role of <strong>Streaming Engineer</strong> emerges, capable of ensuring these processes run with the necessary low latency.</p>
<p><a class="embed-card" href="https://docs.databricks.com/aws/en/data-engineering/batch-vs-streaming">https://docs.databricks.com/aws/en/data-engineering/batch-vs-streaming</a></p>

<h3 id="heading-api-based-integration">API-Based Integration</h3>
<p>Internal and external systems often expose data or operations through an API instead of direct database access.</p>
<p>An <a href="https://youtu.be/6STSHbdXQWI?si=m1r71R_cDfgDyIBU"><strong>Application Programming Interface</strong></a> <strong>(API)</strong> is a contract through which one system exposes selected data or operations without revealing its internal implementation. You can think of it as a defined set of calls or resources that other software may use.</p>
<p>The university might send a text address to a geographic API and receive coordinates. When a student requests transportation, an internal API could accept an authenticated student identifier and return an eligibility result without exposing the underlying academic record.</p>
<p>Some data platforms expose controlled query APIs, but public services should avoid accepting unrestricted SQL from clients. The API contract should expose only the operations and data that the consumer is authorized to use.</p>
<p>Technologically, the most common practice is to use an API via the HTTP protocol, exchanging data in JSON format and following a REST style, although there are alternatives like gRPC, GraphQL, or SOAP. Regardless of the implementation technology, the API must clearly define its contract, which can be documented using OpenAPI or AsyncAPI.</p>
<p>APIs are usually designed and implemented by a <strong>Backend Engineer</strong> or <strong>API Engineer</strong>, while an integration is designed by an <strong>Integration Architect</strong>, regardless of whether the sources are accessed through an API or not.</p>
<h3 id="heading-etl-and-elt">ETL and ELT</h3>
<p>If we focus on the ingestion process, data must be extracted from a source and inserted into another system. But the target system usually has a different schema than the sources. Each source stores data in a specific organization to solve a problem, while the target system structures data differently, mainly because it integrates information from multiple sources.</p>
<p>For example, a data source might store records with some student information <strong>(name, date of birth, email)</strong>, while the target system where integration is intended stores records with that information along with each student's payment data, possibly changing some fields <strong>(name, age, card number)</strong>. This means student records need to be transformed, such as calculating age from the date of birth.</p>
<p>Real integrations usually need more transformations because source and target structures differ. The boundary isn't always strict: teams may transform data for compatibility, quality, privacy, enrichment, or later analysis at several stages of the flow.</p>
<p>In summary, the transformations referred to here constitute what's known as <a href="https://aws.amazon.com/what-is/etl/"><strong>Extract, Transform, and Load</strong></a> <strong>(ETL)</strong>. Basically, it's a process consisting of a series of steps where data is selected and extracted from a source, transformed to fit the target data model, and loaded.</p>
<p>In the previous example, the only step needed would be converting the date of birth into an age, assuming the data types of the other fields match.</p>
<p>An ETL is suitable when you need to strictly control the information before it enters the destination. But there's also <a href="https://www.databricks.com/blog/what-is-elt"><strong>Extract, Load, and Transform</strong></a> <strong>(ELT)</strong>, which first loads the data into the target system and then transforms it once loaded. This approach is common in cloud Data Warehouses and Lakehouses because it allows for preserving an original version and reusing it for various purposes.</p>
<p>For example, with ELT, the university could load authorized student records and <a href="https://en.wikipedia.org/wiki/Raw_data"><strong>raw</strong></a> provider transaction references into a protected Data Lake before applying analytical transformations. It shouldn't copy full card details or bypass security checks simply because the layer is "raw." Keeping source-like data can support reprocessing, but retention, minimization, and access policies still apply.</p>
<p>Teams can implement these processes with Apache Spark, AWS Glue, and Azure Data Factory. Data Engineers usually design the end-to-end flow, while <strong>Analytics Engineers</strong> often define transformations inside the analytical platform.</p>
<h3 id="heading-data-exchange-standards">Data Exchange Standards</h3>
<p>As you've just seen, the differences between source and destination models require transformations.</p>
<p>To reduce the number of transformations needed for integration, there are <strong>Data Exchange Standards</strong>, which are common rules about the structure, format, and meaning of the data. Their goal is to encourage, whenever possible, the use of a "unique" or common structure so that all systems structure the data as similarly as possible, avoiding transformations when exchanged.</p>
<p>For example, the university could define an exchange model with fields such as <strong>(student_id, name, date_of_birth, email)</strong>, along with their formats and semantics. If a consumer needs age, the contract should define the date on which it's calculated so the value doesn't become ambiguous. <strong>Data Exchange Standards</strong> don't have to dictate internal storage. They define the representation used at the boundary.</p>
<p>These rules can be grouped into what's known as a <strong>Canonical Data Model</strong>, documented with OpenAPI or AsyncAPI, among other tools. The responsibility for their definition falls on a <strong>Data Architect</strong> or <strong>Data Modeler</strong>, while a Data Engineer or Integration Engineer is the one who ultimately implements the application of these rules in various systems.</p>
<h3 id="heading-schema-management">Schema Management</h3>
<p>Many systems use a schema that defines field names, types, and constraints. A student record might begin as <strong>(name, date_of_birth, email)</strong> and later gain a phone field. Schemas therefore evolve as requirements change.</p>
<p><a href="https://docs.cloud.google.com/managed-service-for-apache-kafka/docs/schema-registry/schema-lifecycle"><strong>Schema Management</strong></a> versions and governs those changes so producers and consumers can coordinate safely. <a href="https://youtu.be/vQ4mPepAM7Q?si=lgmDoIIWeXHO60mO"><strong>Compatibility</strong></a> policies state which changes a system can accept without breaking existing data or consumers.</p>
<p>Here, we can make a distinction between <strong>backward compatibility</strong> and <strong>forward compatibility</strong>. Backward compatibility refers to the ability of a system using a new schema to correctly read or process data saved or emitted with an old schema. Forward compatibility refers to the ability of a system to use an old schema to read, process <em>(or at least safely ignore)</em> data saved or emitted with a new schema without causing errors. In this context, the ideal is to achieve complete compatibility in both directions.</p>
<p>Teams can express schemas with JSON Schema, Apache Avro, Protocol Buffers, and similar technologies, then version compatible formats in Confluent Schema Registry or AWS Glue Schema Registry. Data Architects and Data Modelers define the shared approach with the engineers who produce and consume the data.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/3_12AZ0CEeo" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h2 id="heading-data-quality">Data Quality</h2>
<p>Integration can combine data from several sources, but a technically successful integration doesn't guarantee useful results. The output may still contain missing values, incomplete records, contradictions, or duplicates that affect its intended use.</p>
<p><a href="https://www.ibm.com/think/topics/data-quality"><strong>Data Quality</strong></a> is the capability that measures and improves whether data is <a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC9299818/"><strong>fit for purpose</strong></a>, in other words, suitable for its intended use.</p>
<p>Quality isn't an absolute label that makes data perfect for every situation. It depends on the intended use. A city of residence may be enough for aggregate demographic statistics but not enough to arrange a pickup. Data should meet measurable requirements for the task at hand.</p>
<p>Generally, the responsibility for maintaining data quality doesn't fall on a single person. Typically, a <strong>Data Quality Manager</strong>, along with <strong>Data Owners</strong>, evaluates which data is most critical for an organization, the impact of potential errors, and what level of quality is acceptable.</p>
<p>Then, a <strong>Data Quality Analyst</strong> analyzes and monitors data practically to ensure its quality, while <strong>Data Engineers</strong> and development teams implement necessary processes to achieve the required quality. These people don't use specific technologies to manage data quality but rely on other technologies like SQL.</p>
<h3 id="heading-data-quality-dimensions">Data Quality Dimensions</h3>
<p>Data quality is a measurable property through Data Quality Dimensions, which are observable characteristics of the data. Each one addresses a different question about the data and can apply to a single piece of data or an entire record:</p>
<ul>
<li><p><strong>Accuracy:</strong> Checks if the data correctly represents reality.</p>
<ul>
<li><em>Example:</em> A student's address is accurate if it matches their real address. Otherwise, it doesn't correctly reflect reality.</li>
</ul>
</li>
<li><p><strong>Completeness:</strong> Checks if all necessary data for a specific use is present.</p>
<ul>
<li><em>Example:</em> Imagine a registration form requires a name, surname, and phone number, and the user doesn't provide their phone number, or that data is lost. The registration record would be <strong>incomplete</strong> if finalized, as the phone field would be null.</li>
</ul>
</li>
<li><p><strong>Uniqueness:</strong> Ensures a piece of data or record doesn't appear more than once.</p>
<ul>
<li><em>Example:</em> When a student enrolls in a university, the database should have one record with their data, not a duplicate, unless design reasons require it.</li>
</ul>
</li>
<li><p><strong>Consistency:</strong> Ensures different representations of data don't contradict each other.</p>
<ul>
<li><em>Example:</em> If a student's email or phone number must be present in multiple places across one or more systems, its value must be the same. It can't appear as one email in one place and a different email elsewhere for the same student. That wouldn't be consistent.</li>
</ul>
</li>
<li><p><strong>Timeliness:</strong> Checks if the data is updated and available when needed.</p>
<ul>
<li><em>Example</em>: When a student requests a taxi, they should be able to get their real-time location data, available and updated with low latency for use.</li>
</ul>
</li>
<li><p><strong>Validity:</strong> Ensures the data respects defined type, format, range, and constraints.</p>
<ul>
<li><em>Example:</em> If a registration request status can be <code>ACCEPTED</code> or <code>REJECTED</code>, those field values can't be different and must be stored in the defined format. Otherwise, they wouldn't be valid according to defined constraints and business rules.</li>
</ul>
</li>
</ul>
<p>These dimensions are interrelated, and in practice, some may be more critical for data use. For example, timeliness is crucial when a student requests a taxi, as they expect to see their real-time location immediately. Meanwhile, uniqueness is key for financial data, as a payment record can't exist multiple times, which would be a particularly severe error.</p>
<h3 id="heading-data-profiling">Data Profiling</h3>
<p><a href="https://youtu.be/HtaYjVwW-Mo?si=pIW24OtUnEBBqYhD"><strong>Data Profiling</strong></a> helps a team understand the current state of a dataset. It inspects structure and content, calculates statistics, and looks for patterns or anomalies. A profile might report null percentages, distinct counts, minimum and maximum values, type patterns, and relationships between fields.</p>
<p>For example, if the university keeps a table with students' personal data, it could be checked that names are stored in a text field, not numeric, or that no record has null values, among other more complex checks.</p>
<p>Relationships between columns and tables can also be analyzed in a relational database, allowing verification that all enrollments are associated with an existing person and subject, as otherwise there would be incomplete and inconsistent data.</p>
<p>Profiling alone can't tell you whether the data is fit for a purpose. A null may be a defect in one field and valid in another. A <strong>Data Quality Analyst</strong> therefore interprets the profile with Data Stewards and domain experts, using tools such as SQL, pandas, or Apache Spark according to the platform and volume.</p>
<h3 id="heading-data-quality-rules">Data Quality Rules</h3>
<p><strong>Data Quality Rules</strong> turn requirements into specific, measurable conditions. They help a team detect when data is unsuitable for an intended use and decide what should happen next.</p>
<p>Profiling discovers what the data looks like, while rules state what acceptable data must look like. Examples include:</p>
<ul>
<li><p>The student's contact email can't be empty and must match the organization's accepted email format.</p>
</li>
<li><p>The distance to the campus must be a decimal number greater than zero.</p>
</li>
<li><p>The same taxi ride can't be recorded twice. The student's charge may be zero, while the provider cost must be recorded in the authorized finance system so the university can manage its budget.</p>
</li>
</ul>
<p>The rules are actually treated as a type of metadata, so they must be documented and versioned accordingly. The <strong>Data Steward</strong> and <strong>Data Owners</strong> design and validate them based on their business sense, while the <strong>Data Quality Analyst</strong> and <strong>Data Engineer</strong> turn them into executable checks. Finally, the rules are expressed in the appropriate technology, such as a <a href="https://en.wikipedia.org/wiki/Query_language"><strong>query language</strong></a> (SQL, Cypher, and so on).</p>
<h3 id="heading-data-validation">Data Validation</h3>
<p><a href="https://www.ibm.com/think/topics/data-validation"><strong>Data Validation</strong></a> executes rules to decide whether data meets established requirements. Unlike profiling, which explores the data's current state, validation compares values and records with explicit conditions.</p>
<p>The enrollment form may require a student's name, but the API and database should still validate it because client-side checks can be bypassed and data can fail in transit. A relational database can enforce conditions with <code>NOT NULL</code>, <code>UNIQUE</code>, <code>CHECK</code>, foreign keys, and other controls. Application and pipeline checks can handle rules that span systems or require more context.</p>
<p>Data Quality Analysts help define and evaluate these checks, while Data Engineers, Software Engineers, Analytics Engineers, and database specialists implement them at the right layers.</p>
<h3 id="heading-data-cleansing">Data Cleansing</h3>
<p>Validation may show that all records meet the rules. When some fail, the team needs a defined response: reject, quarantine, correct, enrich, or accept the record with a documented exception.</p>
<p><strong>Data Cleansing</strong> detects and corrects known defects so data can meet its requirements. The right transformation depends on the field, the rule, and whether the team can determine the correct value safely. For example:</p>
<ul>
<li><p>To avoid inconsistencies, a rule might specify that names shouldn't contain spaces at the beginning or end. So, if a name like <code>' Chloé Moreau '</code> appears, the rule would determine that the data isn't suitable, and it could be transformed by removing the extra spaces to restore its quality.</p>
</li>
<li><p>Another rule might require that all dates use the format <code>YYYY-MM-DD</code>. Thus, if a date like <code>'15/09/2025'</code> appears, the data wouldn't comply with the rule, but it could be transformed to <code>'2025-09-15'</code> to fit the defined format.</p>
</li>
</ul>
<p>Depending on the data, the rule, and the problem it presents, some transformations can be performed automatically, while others may require more supervision to be done correctly. For instance, spaces in a name can be easily detected and removed, but other issues may be more complex and require manual transformation.</p>
<p>Data Engineers, Analytics Engineers, application teams, or operational staff may perform cleansing, while the Data Quality Analyst and Data Steward validate the approach. The process should preserve enough traceability to explain what changed and why. Cleaning a symptom doesn't replace fixing the source of the defect.</p>
<h3 id="heading-data-quality-monitoring">Data Quality Monitoring</h3>
<p>Validation shouldn't happen only when data first enters a system. <strong>Data Quality Monitoring</strong> runs relevant rules and measurements over time, stores the results, and alerts teams when quality degrades.</p>
<p>For example, the university can schedule the automatic execution of quality rules on student data every night. The system would check conditions such as complete addresses, non-negative distances to the campus, and valid date formats. These results can be stored and displayed on a dashboard, allowing for the detection of trends like a sudden increase in negative distance values after an update. This way, the team responsible for the change can quickly identify and correct the problem's origin.</p>
<p>These periodic evaluations are carried out with AWS Glue Data Quality or Microsoft Purview, among other technologies maintained by Data Engineers and DataOps teams.</p>
<h3 id="heading-issue-management">Issue Management</h3>
<p>When a quality problem appears, <strong>Issue Management</strong> records, prioritizes, investigates, and resolves it. Priority depends on the impact on people, decisions, compliance, and business processes, not only on the number of bad rows.</p>
<p>For example, if due to some error, all distances start showing as negative and students are denied access to transportation services, it impacts the user experience and could have more serious consequences if a student can't attend an important exam. So issues must be managed as quickly as possible.</p>
<p>Generally, this management follows these phases:</p>
<ol>
<li><p><strong>Registration and classification:</strong> When a rule is violated, the incident is documented, including its severity and who is responsible for the affected rule or data domain.</p>
</li>
<li><p><strong>Containment:</strong> Depending on the severity or impact of the quality loss, measures are taken to prevent that impact from materializing. For example, if a rule states that payment records must not be duplicated and duplications are detected, the measure might be to temporarily block all payments until the issue is resolved.</p>
</li>
<li><p><strong>Analysis:</strong> Data lineage is used to debug processes and locate the cause of the problem.</p>
</li>
<li><p><strong>Correction:</strong> Once the cause is identified, the problem is corrected, and the rules are re-executed, validating and documenting the resolution.</p>
</li>
</ol>
<p>If duplicate payment records appear, a <strong>Data Quality Analyst</strong> may detect and coordinate the issue, the Data Owner sets the business priority, and a Data Engineer or application team fixes the technical cause. Finance and compliance teams may also need to verify the correction.</p>
<p>In short, quality dimensions define what matters for a use case. Profiling shows the current state, rules formalize expectations, validation tests them, cleansing handles suitable corrections, and monitoring detects changes. Issue Management then coordinates the response when a problem reaches production.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/5HcDJ8e9NwY" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h2 id="heading-data-engineering">Data Engineering</h2>
<p>We've discussed systems that store, exchange, protect, and validate data. Now we can look at how teams build the ingestion processes, pipelines, and transformations that connect those systems in practice.</p>
<p><a href="https://www.databricks.com/blog/what-is-data-engineering"><strong>Data Engineering</strong></a> designs, builds, and operates the processes and components that collect and prepare data. It moves data from one or more sources into the systems where people and applications need it, including platforms such as Data Warehouses and Data Lakes.</p>
<p>Data Engineering works across architecture, storage, integration, and quality, although it doesn't replace those disciplines. That overlap is why Data Engineers have appeared in many earlier sections.</p>
<p>The implementation may be as small as a scheduled SQL transformation or as large as a distributed streaming pipeline. In either case, Data Engineering manages dependencies, automates repeatable work, tests changes, and monitors execution.</p>
<p>The goal is to let other professionals use trustworthy data without rebuilding the whole path back to every source.</p>
<p>For example, imagine the university wants to create a dashboard for the management team to analyze the monthly cost of the transportation service. To do this, it's not enough to query a single database, as travel data might be in one database while cost or payment information might be with the transportation company.</p>
<p>Additionally, each source updates at a different frequency and uses its own schema, so Data Engineering here would serve to build a process that performs steps such as:</p>
<ol>
<li><p><strong>Extract</strong> data from each source.</p>
</li>
<li><p><strong>Validate</strong> its quality through rules.</p>
</li>
<li><p>Apply the required <strong>transformations</strong>, including cleansing defects and standardizing dates, units, and identifiers.</p>
</li>
<li><p><strong>Insert</strong> them into a target system, such as a Data Warehouse, Data Lake, or similar.</p>
</li>
<li><p>Once inserted, they may need to be <strong>aggregated</strong> or processed as required for later use.</p>
</li>
</ol>
<p>The <a href="https://youtu.be/_-DzZeixu0w?si=nXc6z6s0TA-blHPb"><strong>Data Engineer</strong></a> designs and implements these processes with Data Architects, Data Stewards, and Data Quality Analysts. Together, they make sure the solution meets its technical and organizational requirements. Analytics Engineers, Data Analysts, Data Scientists, applications, and other consumers use the results.</p>
<p>And if the infrastructure is large enough, other professionals like <strong>Data Platform Engineers</strong>, <strong>DevOps Engineers</strong>, and <strong>Site Reliability Engineers (SRE)</strong> may be involved to assist in its operation.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/0Hd5vYqin7w" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h3 id="heading-data-pipelines">Data Pipelines</h3>
<p>A <strong>Data Pipeline</strong> is a sequence of automated tasks that moves and processes data from one or more sources to one or more targets. A task may read, validate, transform, route, or write data, then pass a result to another task.</p>
<p>At the university, a pipeline might extract authorized transaction references, trip records, and enrollment data, transform them into a common target schema, and load them into a Data Warehouse. Analysts can then use the curated result for reports and dashboards.</p>
<p>A pipeline can run in batch or streaming mode. A full load reads the complete selected dataset, while an incremental load processes records that are new or changed since a known point. One valuable design property is <a href="https://www.prefect.io/blog/the-importance-of-idempotent-data-pipelines-for-resilience"><strong>idempotence</strong></a>: safely repeating the same input or run shouldn't create unintended duplicates or inconsistent results.</p>
<p>Other significant properties include scalability, so a large volume of data doesn't compromise execution viability, and traceability to know when it's executed and the results it produces.</p>
<p>Pipelines are usually designed and implemented by a Data Engineer, but sometimes Integration Engineers or Analytics Engineers assist, depending on the final use of the data.</p>
<p>The technologies used for implementation vary greatly depending on the infrastructure. A pipeline may include queries in SPARQL, SQL, transformations done in Python, Apache Spark, or Apache Flink, and even use cloud services like Google Cloud Dataflow.</p>
<h3 id="heading-pipeline-orchestration">Pipeline Orchestration</h3>
<p>After defining a pipeline's tasks, inputs, outputs, sources, and targets, you need to coordinate their dependencies. That coordination is <strong>orchestration</strong>.</p>
<p>For example, imagine a pipeline where student and travel data is obtained first, followed by payment data, and these are to be inserted into a Data Warehouse that only accepts records with both payment information and personal data of a student. With these requirements, data from all sources must be obtained before insertion, as they need to be combined. This might not be the case in other pipelines where information from each source can be inserted as it's obtained.</p>
<p>These dependencies in a pipeline are commonly represented with a <strong>Directed Acyclic Graph (DAG)</strong> where each node is a task and each connection indicates a dependency. It can also serve as an internal data structure for orchestration software to precisely decide when a task is ready to execute and what should happen based on its result.</p>
<p>Among the most commonly used technologies for orchestration are Apache Airflow, Dagster, and Prefect, as well as cloud services like Azure Data Factory, AWS Step Functions, or Google Cloud Composer.</p>
<h3 id="heading-data-transformation">Data Transformation</h3>
<p>Many pipeline tasks transform the structure, representation, or content of data so a later consumer can use it.</p>
<p>Transformations can be simple, like converting kilometers to meters, normalizing a date to a common format, or renaming a field. Others are more complex or follow more abstract business rules, such as linking taxi routes with academic schedules to automatically validate if a trip coincides with a mandatory in-person class, thus detecting improper use of the service or any issues. Some transformations may also involve filtering, removing duplicates, or aggregating data.</p>
<p>When data transformations are performed, the data transitions from being newly obtained from a source to being ready for use. Here, we can establish a classification based on the level of transformation the data has undergone:</p>
<ul>
<li><p><strong>Raw:</strong> Data kept close to the source representation. For example, a provider supplies the date string <code>05/03/2026</code>, whose intended day/month order must be documented.</p>
</li>
<li><p><strong>Staging:</strong> Data is validated and standardized for further processing. Once the source meaning is known, the date could become the unambiguous ISO value <code>2026-03-05</code>.</p>
</li>
<li><p><strong>Curated:</strong> At this level, the data is enriched, combined with other data, and considered ready for final use. For example, assuming the previous date corresponds to a trip, it can be combined with other data to create a record of that trip enriched with payment information.</p>
</li>
</ul>
<p>Transformations focus on converting raw data into staging and curated data. Technically, implementation can be done using various technologies depending on the systems involved and company decisions. Primarily, you'll use languages like Python, R, SQL, or frameworks like Apache Spark.</p>
<h3 id="heading-workflow-automation">Workflow Automation</h3>
<p>A pipeline may also check source availability, validate quality, manage approvals, and send notifications. <strong>Workflow Automation</strong> coordinates these actions in the required order so repeatable work doesn't depend on someone running every step by hand.</p>
<p>It's important to differentiate between the pipeline and the workflow. The pipeline describes the path of the data and its transformations. On the other hand, the workflow includes tasks that don't directly transform the data but are essential for the execution of a pipeline.</p>
<p>For example, when the university receives a file from the transportation company, the workflow can validate its format, monitor the pipeline execution, and update data lineage tools.</p>
<p>But automating a workflow doesn't always mean eliminating human intervention. For instance, a rule might be set to detect if personal data appears in a source when it shouldn't. If this rule detects personal data, a Data Steward intervenes to approve the change or reject it and take appropriate action.</p>
<p>Finally, workflows are implemented using orchestrators like Apache Airflow, Dagster, or Prefect, along with CI/CD systems and incident management tools.</p>
<h3 id="heading-data-testing">Data Testing</h3>
<p>When automating the execution of a pipeline, even if manual oversight isn't completely eliminated, much of the process will run with the possibility of errors in its implementation. Even with a perfect implementation, errors can occur that affect the data and cause failures in the pipeline tasks.</p>
<p><strong>Data Testing</strong> checks both transformation code and the data moving through the pipeline so teams can catch defects before they affect consumers.</p>
<p>The test suite should cover realistic ways that code, schemas, data, dependencies, and infrastructure can fail. Data tests and Data Quality rules overlap, but teams may apply them for different reasons.</p>
<p>A quality rule expresses a business or fitness requirement, while a pipeline test may verify a technical precondition or expected transformation. The same check can serve both purposes.</p>
<p>Common test types include:</p>
<ul>
<li><p><strong>Unit tests:</strong> These verify that the code for a transformation is correct given certain inputs and the respective outputs it should produce. For example, if a transformation converts a distance from kilometers to meters, it could be tested with inputs <code>18</code>, <code>4</code>, <code>6</code> and outputs <code>18000</code>, <code>4000</code>, <code>6000</code>.</p>
</li>
<li><p><strong>Schema tests:</strong> These are performed on the data to ensure its structure and format are suitable for a specific task. For instance, when receiving a student's age stored as the number <code>42</code>, a schema test would verify that this data is of integer type.</p>
</li>
<li><p><strong>Integration tests:</strong> These check that various components of an architecture or system can interact as expected. For example, an integration test might verify that a university's Data Warehouse can receive data from an academic database.</p>
</li>
<li><p><strong>End-to-end tests:</strong> These involve executing the entire pipeline to ensure the result is correct given initial data.</p>
</li>
<li><p><strong>Reconciliation tests:</strong> Compare counts, totals, or control values across stages. If a documented filter should retain 50 of 100 input records, the test verifies both the output count and the reason for the exclusions.</p>
</li>
<li><p><strong>Performance tests:</strong> Given the complexity of some pipelines, performance tests are conducted to evaluate if their execution is feasible within a certain time and with available resources.</p>
</li>
</ul>
<p>At the university, before deploying a pipeline, datasets with fictional information, also known as synthetic datasets, could be constructed for use in testing. This way, all these types of tests could be executed to verify that tasks are performed correctly, data has the expected properties after each transformation, and the process is completed within a specified time.</p>
<p>The test technology follows the pipeline. A Python transformation could use <strong>pytest</strong>, while SQL can support reconciliation and schema checks. Data Engineers own most pipeline tests, and Platform or DevOps Engineers help integrate them into automated delivery and runtime environments.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/cHYq1MRoyI0" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h3 id="heading-data-versioning">Data Versioning</h3>
<p>Data pipelines generally undergo changes due to modifications in business requirements, changes in sources, or other reasons. So it's essential to maintain a history of what has happened with a pipeline over time, allowing you to track its evolution up to a specific point, primarily to facilitate error debugging.</p>
<p><strong>Data Versioning</strong> keeps a history of the assets needed to reproduce a result. Depending on the use case, this can include transformation code, schemas, configuration, reference data, model inputs, and snapshots or versions of the dataset itself.</p>
<p>For example, imagine a report states that $10,000 was spent on taxis in a month, but upon checking later, the system says the amount was $8,000 for the same month. This discrepancy could be due to an error or a change in the policies used to calculate that cost, such as no longer counting canceled trips.</p>
<p>To determine if this situation is an error, versioning allows access to previous versions of the pipelines involved in that calculation to see how the figure was obtained.</p>
<p>Teams commonly use Git for code, configuration, and text-based schemas. Table formats such as Apache Iceberg, Delta Lake, and Apache Hudi can preserve data snapshots and change history for supported tables. Reproducibility may require both.</p>
<h3 id="heading-data-platform-operations">Data Platform Operations</h3>
<p>Once implemented and versioned, a pipeline needs an infrastructure to run on, which refers to hardware that can be on university servers or in the cloud. It may require storage for data, computing capacity for transformations, an orchestrator to coordinate tasks, and specialized systems to ensure data and process security. These components together form a <a href="https://www.mongodb.com/resources/basics/what-is-a-data-platform"><strong>Data Platform</strong></a>, which is the technological environment where pipelines and other processes are executed.</p>
<p>The platform itself must be managed and maintained, as it's not a system that operates completely autonomously but requires supervision. This management process is known as <strong>Data Platform Operations</strong> and encompasses a series of tasks aimed at ensuring the platform is ready to execute pipelines securely, stably, and efficiently.</p>
<p>Some of the most fundamental tasks are:</p>
<ul>
<li><p><strong>Provisioning and scaling of resources:</strong> The number of machines needed by databases and platform components at any given time is configured.</p>
</li>
<li><p><strong>Environment management and isolation:</strong> Reserved environments are created for testing, development, and production, with the latter providing services to the end user.</p>
</li>
<li><p><strong>Permission management:</strong> Permissions are determined for each professional to perform their tasks, preventing security breaches.</p>
</li>
<li><p><strong>Cost control and optimization:</strong> Resource consumption is monitored to avoid overspending, aiming to provide the service with minimal consumption.</p>
</li>
</ul>
<p>For example, a pipeline that calculates the monthly cost of taxi usage might need to connect to a transportation company's API, transform the data, and store it in a Data Warehouse.</p>
<p>To achieve this, the platform must provide the necessary computing resources to perform the transformations, store the data, and allow a secure connection with the API. Thus, proper platform management is critical to ensure the pipeline runs correctly.</p>
<p>A <strong>Data Platform Engineer</strong> commonly leads this work and understands the services on which the platform runs, such as AWS, Azure, Google Cloud, Databricks, or Snowflake. Docker packages suitable workloads, Kubernetes can orchestrate containers when the complexity justifies it, and Terraform defines infrastructure as code. Infrastructure as code improves repeatability, but it doesn't make services automatically portable between cloud providers.</p>
<h3 id="heading-data-observability">Data Observability</h3>
<p>Data platforms can fail in subtle ways even when every job reports success. <strong>Data Observability</strong> helps teams understand the health of data and the systems that produce it so they can detect, investigate, and reduce the impact of failures.</p>
<p>Observability lets you infer a system's state from the signals it produces. In a data context, those signals include freshness, volume, schema, distribution, quality results, lineage, job status, logs, metrics, and traces.</p>
<p>Monitoring checks known conditions, such as whether a job completed and whether freshness or volume stayed within expected limits. Infrastructure signals such as CPU and memory can help explain failures, while data-level signals show whether consumers received the right output.</p>
<p>For example, if a data pipeline produces dozens of records when it should produce hundreds, monitoring allows you to detect these changes in results,. It can also show other relevant metrics obtained at those same moments, such as the CPU usage of each task involved in the pipeline, helping you detect if any tasks are failing and preventing data from propagating to the end.</p>
<p>For observability to guide action, teams can define <strong>Service Level Indicators (SLIs)</strong> for relevant properties and <strong>Service Level Objectives (SLOs)</strong> for the expected level. An SLI might measure the age of the latest attendance data, while the SLO could state that 99% of daily updates must be available by 7:00 AM. An alert tells the team when the pipeline risks missing that commitment.</p>
<p>The most well-known technologies in observability are Prometheus and Grafana, frequently used to collect and visualize metrics. There are also OpenTelemetry for managing telemetry data and logs, and OpenLineage for monitoring data lineage in real time.</p>
<p>Here, a <strong>Data Engineer</strong> might be responsible for implementing the appropriate observability mechanisms. But they don't always do it alone, as an SRE, Platform Engineer, or DataOps team may collaborate in maintaining these mechanisms.</p>
<h3 id="heading-data-contracts">Data Contracts</h3>
<p>Observability helps detect errors such as failed jobs, stale data, abnormal volumes, and unexpected schema changes. If a taxi provider changes geographic coordinates from numbers to text without notice, for example, downstream processes may fail even though the network connection still works.</p>
<p><a href="https://www.ibm.com/think/topics/data-contract"><strong>Data Contracts</strong></a> reduce this risk by making expectations between producers and consumers explicit. They define the structure and characteristics of the data, along with how teams communicate and version changes. Observability still verifies the contract in operation.</p>
<p>More specifically, a Data Contract can define schema, types, formats, semantics, quality rules, ownership, delivery frequency, latency, and change-management expectations.</p>
<p>For example, the transportation company might agree that each trip event includes <strong>(trip_id, student_reference, provider_vehicle_id, price, origin, destination)</strong>. The contract could define <code>price</code> in euros and coordinates as numeric latitude/longitude pairs, set privacy limits on <code>student_reference</code>, and require a new contract version for an incompatible change.</p>
<p>Also, the contract isn't just documentation. Checks are implemented to verify compliance so that any change, for safety, doesn't affect data pipelines, as changes can impact both availability and security.</p>
<p>To define a Data Contract, data schemas are often represented in JSON Schema, Apache Avro, Protocol Buffers, or similar technologies, although standards like the <a href="https://bitol-io.github.io/open-data-contract-standard/v3.1.0/"><strong>Open Data Contract Standard</strong></a> <strong>(ODCS)</strong> are also used.</p>
<p>The contract is developed and reviewed by Data Engineers and Analytics Engineers within the organization, who coordinate with professionals from other companies, such as Software Engineers who know what data their source produces. At a higher level, Data Owners and Data Stewards are involved to validate the semantics, quality, and usage conditions of the data.</p>
<h3 id="heading-dataops">DataOps</h3>
<p>Data Engineering involves many people and components. Even a pipeline that works today can become unreliable if teams don't coordinate changes to sources, contracts, code, infrastructure, and quality rules.</p>
<p><strong>DataOps</strong> is an approach to improving that collaboration and delivery process. It aims to shorten the path from a business need to trustworthy data while maintaining quality, security, and traceability.</p>
<p><a href="https://www.databricks.com/blog/what-is-dataops"><strong>DataOps</strong></a> isn't a specific technology. It's a set of practices such as versioning code, automating tests, reviewing and deploying changes through controlled environments, and monitoring production pipelines. It adapts ideas from agile delivery and software operations to data-specific concerns.</p>
<p>For example, imagine the university starts working with a new taxi company. The first step could be creating a Data Contract with the conditions for data delivery. Then, a <strong>Data Engineer</strong> would implement all the necessary software for obtaining it through a connector and store it in <strong>Git</strong>.</p>
<p>Also, before deploying it in production, you should conduct data and code tests to ensure functionality. Finally, after deployment, it would be monitored through metrics like the volume of data extracted, its quality, and latency.</p>
<p>Data Engineers, Analytics Engineers, Data Stewards, Data Owners, Platform Engineers, SREs, and consumers all contribute to DataOps. The practices work only when the people who produce, operate, and use data share responsibility for reliable delivery.</p>
<p>Technologically, <a href="https://youtu.be/HNgpk9IUfK4?si=ANAVTJnGL_q5p3vU"><strong>DataOps</strong></a> relies on tools we've already discussed, like Git for versioning and CI/CD tools for automating tests and deployments, among others. But its value doesn't come from a specific tool. It comes from adopting best practices in their use.</p>
<p>Many of these ideas come from DevOps. Nonetheless, DataOps adapts them to data work, incorporating specific aspects like quality, semantics, lineage, and the relationship between producers and consumers.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/mAFoROnOfHs" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h3 id="heading-devops">DevOps</h3>
<p>As I just mentioned, DataOps adopts ideas from <a href="https://youtube.com/playlist?list=PLWKjhJtqVAbkzvvpY12KkfiIGso9A_Ixs&amp;si=L4Aj9YXaWYWWJiWK"><strong>DevOps</strong></a>. DevOps refers to a set of best practices that help coordinate software development and the deployment of systems, all with the goal of ensuring that changes can be tested, deployed, and maintained in an automated and reliable manner.</p>
<p>Among its main practices is <strong>Continuous Integration (CI)</strong>, which involves integrating each code change into a repository so tests are automatically conducted. Then there's <strong>Continuous Delivery</strong> or <strong>Continuous Deployment (CD)</strong>, allowing changes to be deployed automatically in a controlled manner across different environments. Finally we have <strong>Infrastructure as Code (IaC)</strong>, which lets you define infrastructure components programmatically, facilitating their versioning and deployment across various cloud platforms or servers.</p>
<p>For example, when a Data Engineer modifies the connector that extracts data from the taxi company, the change is saved in Git and a CI system automatically runs its tests. If it passes, a new version of the software is built and deployed autonomously in a test environment to continue verifying its functionality until it's deployed in the final production environment.</p>
<p>Common technologies include GitHub Actions, GitLab CI/CD, or Jenkins for automating tests and deployments. Docker is also commonly used for packaging software along with Terraform or OpenTofu for defining infrastructure. Kubernetes can also be used to manage containers when the system's scale and complexity require it.</p>
<p><strong>DevOps Engineers</strong>, <strong>Platform Engineers</strong>, and <strong>SREs</strong> implement and maintain these mechanisms, while Data Engineers use them to deploy their pipelines. The main difference is that DevOps focuses on software and infrastructure delivery and operation, while DataOps also checks data-specific aspects like quality, semantics, lineage, and availability.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/PHsC_t0j1dU" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h2 id="heading-data-warehousing-and-business-intelligence">Data Warehousing and Business Intelligence</h2>
<p>Organizations capture, integrate, and transform data through pipelines, then store it in systems chosen for particular workloads. Operational databases support the applications and transactions that keep day-to-day services running.</p>
<p>Analysis often needs integrated history, stable definitions, and queries that scan many records. Specialized platforms such as Data Warehouses and Data Lakes support that work. <strong>Data Warehousing and Business Intelligence</strong> makes governed analytical data available to people who explore it and use the results in decisions.</p>
<p>These are two related concepts. <strong>Data Warehousing</strong> covers the design and use of a Data Warehouse, which integrates historical data from several sources for repeatable analytical workloads.</p>
<p>Operational and analytical workloads have different priorities and access patterns. Some platforms support both, but teams still face tradeoffs in isolation, performance, freshness, consistency, and cost. Separating the workloads often protects daily operations and gives analysts a model designed for their queries.</p>
<p><a href="https://www.tableau.com/business-intelligence/what-is-business-intelligence"><strong>Business Intelligence</strong></a> <strong>(BI)</strong> covers the practices and technologies used to query, analyze, and present data for decision-making. A Data Warehouse often provides the governed analytical foundation for BI, although BI tools can use other sources too.</p>
<p>For example, a university might integrate trip and finance data in a Data Warehouse. Analysts could compare provider costs, usage, attendance, and budget to assess whether the transportation benefit is sustainable and estimate short-term spending.</p>
<p>Also, in order to conduct these data analyses, build dashboards, and ultimately make decisions, the data needs to be of high quality, protected, and maintained with proper lineage. Any issues in these aspects can influence decision-making.</p>
<h3 id="heading-analytical-data-stores">Analytical Data Stores</h3>
<p>Analytical workloads often scan long time periods, join several sources, and aggregate large numbers of records. Storage designed mainly for operational transactions may not be the best place to run them repeatedly.</p>
<p><a href="https://www.dremio.com/wiki/analytical-data-store/"><strong>Analytical Data Stores</strong></a> are designed for analytical queries, transformations, and aggregations. They still need security and consistency controls, but their performance priorities usually favor scans and calculations across large datasets rather than high-frequency row-level transactions.</p>
<p>The most representative example of an Analytical Data Store is a Data Warehouse, which stores data in a stable and scalable way so that the same analysis process can be repeated over time with an ever-increasing volume of data.</p>
<p>But this is not the only option, as Data Lakes are also oriented toward this type of use, and <a href="https://www.snowflake.com/en/fundamentals/what-is-a-data-mart/">Data Marts</a> offer a smaller-scale analytical environment (usually being subsets of data from a Warehouse) specifically designed to meet the needs of a particular department or business area.</p>
<p>For example, the university could create a Data Mart containing mobility measures and the limited financial context needed to analyze service cost, without exposing irrelevant student details. The team should connect the Mart to lineage, security, quality, and audit controls just as it would any other analytical asset.</p>
<p>Among the most used platforms to implement these systems are Snowflake, Google BigQuery, Amazon Redshift, Microsoft Fabric Data Warehouse, and Databricks SQL. Their design and implementation are the responsibility of an <strong>Analytics Architect</strong> or Data Architect, while <strong>Data Engineers</strong> maintain the data pipelines that supply them with information, and <strong>Analytics Engineers</strong> handle the transformations required after ingestion to facilitate subsequent analysis.</p>
<p>At the administration and maintenance level, there are <strong>Data Warehouse Administrators</strong> or <strong>Platform Engineers</strong>, who monitor performance, manage permissions, and platform costs.</p>
<h3 id="heading-facts-and-dimensions">Facts and Dimensions</h3>
<p>An <strong>Analytical Data Store</strong> may preserve source-like data or organize it into a model, depending on the platform and layer. A Data Lake commonly retains source formats in an early zone, while curated layers and Data Warehouses apply more explicit schemas.</p>
<p>One common analytical approach is the <a href="https://youtu.be/CZM__QtHCB0?si=XSxQtXQosiKHq2dh"><strong>dimensional modeling</strong></a> we talked about earlier. It organizes information into <strong>facts</strong> and <strong>dimensions</strong>. A fact records an event such as a trip, while dimensions provide context for filtering, grouping, and comparison.</p>
<p>A particularly important design choice is <a href="https://www.ibm.com/docs/en/ida/9.1.1?topic=phase-step-identify-grain"><strong>granularity</strong></a>, or grain: exactly what one row of a fact table represents. The team should define it before choosing dimensions and measures so later aggregations remain valid.</p>
<p>For example, the <strong>Trip</strong> fact table might have a grain of <em>"one completed trip."</em> If a student takes two trips on the same day, the table stores two rows, each with its cost, distance, duration, and date key. The university can sum those rows by month. It shouldn't add monthly-total rows to the same fact table because they have a <strong>different granularity</strong> and would cause double counting.</p>
<p>Once the granularity is defined, dimensions should be chosen based on the context describing the fact and the analytical queries expected to be performed. A practical way to identify them is by asking <strong>who, what, when, where, and how</strong> each fact was involved. For example, if each row represents a trip, dimensions like Student, Date, Provider, Origin, and Destination could be used, each with a unique value for that trip.</p>
<p>These dimensions would allow analysis of the geographical areas where trips occur, which transportation company makes more or fewer trips, and so on. This way, dimensions are incorporated that provide a useful perspective for analyzing the facts.</p>
<p>This data modeling is done by an <strong>Analytics Engineer</strong> or <strong>Data Modeler</strong>, along with domain experts like Data Stewards. Then, <strong>Data Engineers</strong> implement the data ingestion and transformations required to adapt the data to the specific final model of each system.</p>
<h3 id="heading-metrics-and-kpis">Metrics and KPIs</h3>
<p>In a dimensional model, facts can be seen as rows composed of values, called <strong>measures</strong>. These measures can help understand what happened during an event over time, but data analysis generally aims to answer questions involving all events over a certain period.</p>
<p>Teams combine measures into repeatable <a href="https://www.nist.gov/itl/ai/ai-standards-and-guidelines-group/metrics-and-measures"><strong>metrics</strong></a>, such as totals, rates, averages, and percentiles. A metric becomes a <a href="https://youtu.be/ItZlTixh6Bs?si=vXN2FCx2ICh5E59Y"><strong>Key Performance Indicator</strong></a> when it's tied to an important objective and helps show whether the organization is meeting it. Here are some examples:</p>
<table>
<thead>
<tr>
<th>Concept</th>
<th>Meaning</th>
<th>Example</th>
</tr>
</thead>
<tbody><tr>
<td>Measure</td>
<td>A value recorded in a fact</td>
<td>A trip cost €18</td>
</tr>
<tr>
<td>Metric</td>
<td>A repeatable calculation over a set of measures</td>
<td>Monthly transportation cost = sum of the cost of trips completed during the month</td>
</tr>
<tr>
<td>KPI</td>
<td>A metric associated with a business objective</td>
<td>Monthly mobility budget consumption, with the hypothetical objective of not exceeding the allocated budget</td>
</tr>
</tbody></table>
<p>As is evident, not every metric is always a KPI. For example, a metric that represents the total number of trips made in a month can be useful for describing transportation service usage, but it will only be a KPI when there's a business objective that involves quantifying that number of trips.</p>
<p>KPIs are often used in dashboards and visualizations, although they generally don't appear in isolation. In this regard, when several KPIs with their current values are gathered and compared with established goals, this gathering is called a scorecard.</p>
<p>Despite both concepts being related, a <strong>scorecard</strong> and a <strong>dashboard</strong> have different purposes. A scorecard aims to determine if goals are being met, while a dashboard helps understand what's currently happening in the organization and why.</p>
<p>The same metric may appear in dashboards, scorecards, reports, and APIs, so teams need a reusable definition. Its documentation should include:</p>
<ul>
<li><p>The name, purpose, and business owner.</p>
</li>
<li><p>The formula that calculates the resulting value of the metric, the sources of the data, and its granularity.</p>
</li>
<li><p>The unit, time period, time zone, and frequency of metric value updates.</p>
</li>
<li><p>The filters and inclusion rules, such as excluding canceled trips from the calculation.</p>
</li>
<li><p>In the case of a KPI, the objective that originates it is documented.</p>
</li>
</ul>
<p>Here, metrics and KPIs are primarily defined by roles like <strong>Business Owners</strong>, <strong>Data Owners</strong>, and <strong>Data Stewards</strong>. On the other hand, their practical implementation is carried out by <strong>Analytics Engineers</strong> and <strong>BI Developers</strong>, and finally, their results are used by <strong>Data Analysts</strong>, among other professionals.</p>
<h3 id="heading-semantic-layers">Semantic Layers</h3>
<p>As I mentioned before, metrics are documented to ensure their meaning and calculation method are well understood. But this doesn't guarantee that all systems adhere perfectly to this documentation.</p>
<p>For instance, monthly cost might be calculated excluding canceled trips, while another system might accidentally include them. In both cases, the same "name" is used for a metric that produces different results.</p>
<p>A <a href="https://www.databricks.com/blog/what-is-a-semantic-layer"><strong>Semantic Layer</strong></a> addresses this problem by centralizing reusable business definitions between stored data and consumption tools. It presents concepts such as Trip, Student, or Course instead of requiring every consumer to rebuild logic directly from tables and joins.</p>
<p>In this way, the formulas and filtering rules that make up each metric are implemented on the <strong>semantic layer</strong>, rather than each analyst writing their own code on a database, Data Warehouse, or corresponding system. This layer acts as an intermediary that translates the calculation of a metric expressed in a business-friendly language into the necessary code for specific systems to perform that calculation, facilitating future metric modifications and portability between different systems.</p>
<p>For example, in the Data Warehouse, there might be a Trip fact table, a Date dimension, and a cost measure in each fact. Here, the semantic layer would define the existence of certain concepts like trip and cost, whose calculations are "mapped" in some way onto the technology used to implement each system.</p>
<p>In this case, the calculation of a <strong>"Total Cost per Month"</strong> metric could be defined on the semantic layer, which would internally translate this into SQL operations, or the corresponding technology, to group trips by month and sum the cost measure of the grouped facts.</p>
<p>The main difference between the documentation of a metric and its implementation in a semantic layer is that the documentation specifies what the metric is and how it is formally calculated, while in the semantic layer this specification is translated into operations in a specific technology that allows the calculation.</p>
<p>Thus, multiple dashboards or reports can reuse the same logic defined on a semantic layer, as sometimes calculations need to be performed on data in different systems.</p>
<p>Technologies used to implement semantic layers include Power BI Semantic Models, LookML, dbt Semantic Layer, and Cube. Analytics Engineers and BI Developers commonly build and maintain these definitions with input from business owners and analysts.</p>
<h3 id="heading-reports-and-dashboards">Reports and Dashboards</h3>
<p>After implementing the <strong>Analytical Data Stores</strong> systems in production and defining some metrics or KPIs, the next step is to create Business Intelligence products that present the analysis results to end users, professionals, or executives.</p>
<p>The most common products are reports and dashboards, though they aren't the only ones, as the analysis results can also lead to a visualization or documentation of a decision-making process, for example.</p>
<p>Let's better understand what each one is and their differences:</p>
<p>A <a href="https://youtu.be/fqKheazewbo?si=auO7hrFX6zQGgoyM"><strong>report</strong></a> is a document that presents detailed and structured information on a specific topic and time period. It may include graphs, metrics, and explanations. Reports can be generated periodically in static formats, like PDF, or be interactive, allowing users to filter or manipulate the presented information.</p>
<p>For example, a university might prepare a monthly report with the transportation service cost broken down by provider, showing canceled trips, the number of students who used it, and so on.</p>
<p>A <a href="https://youtu.be/GDzzh4T_IaM?si=r2t7eHDiIXvLFZza"><strong>dashboard</strong></a><strong>,</strong> the other hand, is a view that brings together the most relevant metrics and KPIs to monitor a situation. It typically contains graphs and visual elements that update more frequently than a report.</p>
<p>For example, a dashboard for the administration could show the consumed budget, the number of enrolled students, and the attendance trend, also allowing results to be filtered by training program if it is interactive.</p>
<p>There are some best practices to follow when you're creating dashboards to make sure they're useful. For example, you should display only a few indicators and only those truly relevant to the dashboard's purpose. Also, choosing a visualization isn't merely decorative, as the charts should help people understand the information presented, and they should follow best practices in their design.</p>
<p>In general, you'll use a dashboard when it's necessary to periodically monitor a small set of indicators and quickly detect changes or deviations. You'll use a report when you need a deeper exploration of a topic, although both products can complement each other.</p>
<p>For example, the administration might use a dashboard to detect an increase in transportation expenses and then consult a monthly report to find out which providers, routes, or periods caused it.</p>
<p>For creating these products, the most commonly used technologies are Microsoft Power BI, Tableau, Looker, Apache Superset, and Metabase. These are primarily used by <strong>BI Developers</strong>, although <strong>BI Administrators</strong> also collaborate in managing the workspace where the products are built. Finally, the results can be interpreted by a <strong>BI Analyst</strong>, who also has the knowledge to develop reports or dashboards in certain situations alongside the <strong>BI Developers</strong>.</p>
<h3 id="heading-self-service-analytics">Self-Service Analytics</h3>
<p>The data analysis process generates products like dashboards or reports, which present specific information structured for a purpose. But sometimes it may be necessary to modify that purpose.</p>
<p>For example, the finance department might have a dashboard designed exclusively to monitor the overall budget the university allocates to taxi services. Yet, the director of a specific master's program might need to cross-reference that transportation data with attendance records from their training program to see if the service provides any benefit, which is a very specific need not addressed by the original dashboard.</p>
<p>The coordinator could ask the technical team to change the dashboard, but every small question would then enter a development queue. <a href="https://www.ibm.com/think/topics/self-service-analytics"><strong>Self-Service Analytics</strong></a> lets authorized users explore governed data and create suitable analyses without depending on a technical specialist for every step.</p>
<p>This approach relies on elements we covered earlier, such as <strong>semantic layers</strong> where metrics are maintained, data catalogs that allow you to quickly locate available information, and business glossaries that standardize the meaning of business concepts. These elements are used by team members who independently build their own visualizations and reports, although they may not have access to all types of information due to existing privacy policies. This is why the process is called <strong>managed self-service</strong>.</p>
<p>For example, if a dashboard shows an increase in transportation expenses, a Master's coordinator could use a semantic layer to define a filter for their program's data. Thus, the semantic layer would ensure the official cost definition is used, while permissions would prevent access to data from other programs or unnecessary personal information.</p>
<p>Finally, it's worth noting that the original dashboard isn't always modified. Instead, the coordinator creates a new one with their changes.</p>
<p>In practice, the viability of this approach is the result of coordinated work by <strong>Analytics Engineers</strong>, <strong>BI Developers</strong>, and <strong>BI Administrators</strong>, primarily. The end users who consume and leverage this capability are <strong>Data Analysts</strong>, <strong>Business Analysts</strong>, and business managers.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/9fFQA-JOXA0" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h2 id="heading-big-data">Big Data</h2>
<p>The data lifecycle runs across an infrastructure of systems and pipelines. Data enters, moves, gets stored and processed, and eventually reaches operational or analytical consumers.</p>
<p>For a moderate workload, a relatively simple architecture may meet the required performance, reliability, and cost targets. As the organization grows, however, it may need to store more data, process events more often, and support more varied formats and use cases.</p>
<p>A database that began on one machine might first scale vertically by gaining more CPU, memory, or storage. At some point, the workload or resilience requirements may justify horizontal scaling across several machines, but that added complexity should solve a measured need.</p>
<p><a href="https://cloud.google.com/learn/what-is-big-data?hl=en"><strong>Big Data</strong></a> deals with datasets and flows whose volume, velocity, variety, or combination pushes beyond the practical limits of conventional tools for a particular organization. The challenge is not simply "a lot of rows". It's meeting the required processing time, reliability, and cost at that scale.</p>
<p>When thinking about Big Data, you might imagine a well-defined threshold beyond which a data set is considered Big Data. But this isn't the case, as the threshold depends on the current infrastructure, the target speed, the cost thr team willing to incur for its management, and the variety in the structure of the information.</p>
<p>A team should adopt a Big Data solution only after assessing whether the current infrastructure misses its performance, reliability, or cost requirements. Distribution may help, but it also adds operational complexity, so the benefits need to justify it.</p>
<p>For example, a university could grow from having 1,000 students to 100,000 due to an expansion of its faculties or the introduction of online classes. If this happens, the databases must support storing all their personal data, as well as the data generated when interacting with various services and platforms like the virtual campus, all at a speed that doesn't compromise service availability or quality.</p>
<p>Big Data draws on many Data Management capabilities at a larger scale. A <strong>Big Data Engineer</strong> is often a Data Engineer who specializes in distributed storage and processing. They work with Data Architects who design the solution and Data Platform Engineers who operate it.</p>
<h3 id="heading-the-3vs-volume-velocity-and-variety">The 3Vs: Volume, Velocity, and Variety</h3>
<p>There's no universal threshold for Big Data, but the 3Vs – <strong>Volume, Velocity,</strong> and <strong>Variety</strong> – provide a useful guide. They aren't three boxes every project must check. They describe pressures that can make a workload harder to manage with the current infrastructure.</p>
<p><strong>Volume</strong> refers to the total amount of data that must be stored and processed. The first challenge here is that data takes up space, so in a large enough volume, some systems may not be able to handle it all. Also, various management processes slow down as the volume increases because all data must go through pipelines or similar processes.</p>
<ul>
<li><em>Example:</em> Volume can be associated with the amount of data produced by students, meaning the more students there are, the more data volume needs to be supported. Each student generates data like login events, which must be stored and processed, taking up space and consuming significant computing resources if the volume is high.</li>
</ul>
<p><strong>Velocity</strong> refers to how quickly data arrives, changes, and must become available to consumers.</p>
<ul>
<li><em>Example:</em> Transportation service taxis must communicate their position and status every few seconds so a student can have a real-time view of available taxis and whether they are near their location. So it's crucial that data is available as quickly as possible to ensure a good user experience.</li>
</ul>
<p><strong>Variety</strong>, as previously mentioned, describes the nature or diversity of data, such as structures, formats, and meanings that data presents.</p>
<ul>
<li><em>Example:</em> An academic database can store enrollments and students in tables using a relational paradigm, while the virtual campus produces logs in semi-structured JSON documents, or a graph-oriented database represents information about students, drivers, and locations with graphs to optimize transportation routes.</li>
</ul>
<p>Volume affects storage, transfer, and processing costs. A team may optimize the data model, partitioning, queries, or retention before distributing the workload. When one machine can no longer meet the requirements economically or reliably, horizontal scaling becomes one option.</p>
<p>Not all data needs real-time processing. A live trip-status update may need seconds, while a historical tuition-payment report can refresh on a daily schedule. The required latency should come from the user and business need, not from a desire to make every pipeline real time.</p>
<p>Finally, variety is one of the most significant properties of data because it determines the heterogeneity of the dataset within the organization. With such diverse data stored in different structures, formats, and representations, it becomes necessary to adopt specific techniques for each variety to ensure efficient and viable management.</p>
<p>These are the properties typically attributed to Big Data. But it's also important to highlight other significant properties, such as <strong>veracity</strong>, which refers to the reliability of the data or <strong>value</strong>, among others.</p>
<h3 id="heading-big-data-architectures">Big Data Architectures</h3>
<p>When the 3Vs exceed the capacity of a "conventional" solution, there are several ways to increase the capacity of an infrastructure to meet these needs. But first, it's useful to define what infrastructure is.</p>
<p><a href="https://www.hpe.com/emea_middle_east/en/what-is/data-infrastructure.html"><strong>Infrastructure</strong></a> is the set of computing, storage, networking, and foundational software resources on which the organization's applications and data systems run.</p>
<p><a href="https://aws.amazon.com/what-is/data-architecture/"><strong>Architecture</strong></a> describes how components use that infrastructure to meet requirements. It defines where systems run, how storage and processing are distributed, and which path data follows from source to consumer.</p>
<p>So if the 3Vs compromise the viability of an existing solution, it may be necessary to modify its architecture. One way to address an increase in volume or velocity, as mentioned before, is <strong>vertical scaling</strong>. This involves improving the hardware, giving each machine more resources. But this can't scale infinitely, which is why <strong>horizontal scaling</strong> exists. More machines are added, and storage and processing are distributed.</p>
<p>Another way to increase speed could be the parallel execution of processes across multiple machines, known as <strong>Massively Parallel Processing (MPP)</strong>.</p>
<p>There are many ways to improve the capabilities of an infrastructure, especially when it comes to processing more data at higher speeds. Managing a greater variety of data, though, is often a challenge without established general techniques, although distribution can help.</p>
<p>To better understand what architecture consists of, think of it as a set of layers where each encompasses certain components that together constitute the path data takes throughout its lifecycle within the organization.</p>
<table>
<thead>
<tr>
<th><strong>Layer</strong></th>
<th><strong>Functionality</strong></th>
<th><strong>Example</strong></th>
<th><strong>Technologies</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>Sources</strong></td>
<td>Origin where data is obtained or generated</td>
<td>Taxi company API and payment platform</td>
<td>REST APIs, PostgreSQL, IoT sensors</td>
</tr>
<tr>
<td><strong>Ingestion</strong></td>
<td>Moving data from sources into the platform</td>
<td>Receiving virtual-campus events and provider trip updates</td>
<td>Apache Kafka, Apache Airflow</td>
</tr>
<tr>
<td><strong>Storage</strong></td>
<td>Persistently storing data</td>
<td>Retaining events, files, and curated analytical tables</td>
<td>Amazon S3, Google Cloud Storage</td>
</tr>
<tr>
<td><strong>Processing</strong></td>
<td>Cleaning and transforming data according to its purpose</td>
<td>Removing duplicate trip records in a data pipeline</td>
<td>Apache Spark, Apache Flink</td>
</tr>
<tr>
<td><strong>Serving</strong></td>
<td>Exposing information for querying</td>
<td>A Data Warehouse exposes integrated trip information and the associated costs</td>
<td>Snowflake, Google BigQuery</td>
</tr>
<tr>
<td><strong>Consumption</strong></td>
<td>Using information for decision-making or any other purpose</td>
<td>Dashboard showing the monthly cost of the transportation service</td>
<td>Power BI, Tableau, Jupyter</td>
</tr>
</tbody></table>
<p>Another important aspect of any architecture is that its layers must implement security, lineage, and observability mechanisms, also ensuring data privacy.</p>
<p>Imagine a student requests a taxi through the virtual campus. The architecture must protect and trace the event. A <a href="https://youtu.be/A3Mvy8WMk04?si=6DNNlsEB9icoBLQz"><strong>streaming</strong></a> flow can update trip status on the portal within seconds, while a later <a href="https://docs.databricks.com/aws/en/data-engineering/batch-vs-streaming"><strong>batch</strong></a> process consolidates the relevant records for cost analysis.</p>
<p>This difference in speeds is another way to adjust the architecture so that certain critical functionalities have the required speed or so that analysis processes that don't need to be performed in real time can handle a larger volume of data.</p>
<p>Finally, the architecture is designed by a <strong>Data Architect</strong> or <strong>Big Data Architect</strong> and implemented by <strong>Data Engineers</strong>, <strong>Streaming Engineers</strong>, or <strong>Software Engineers</strong>. Its maintenance is the responsibility of Data Platform Engineers, Cloud Engineers, and SREs.</p>
<h3 id="heading-big-data-storage-and-processing">Big Data Storage and Processing</h3>
<p>After designing the architecture, its components are implemented, with some dedicated to storing and processing data at the required scale. On one hand, <strong>storage</strong> is responsible for keeping data persistent, secure, and accessible. On the other, <strong>processing</strong> uses computing resources to transform and analyze them, primarily.</p>
<p>The university might retain authorized virtual-campus events, attendance records, and trip information for several years, creating a large storage need. Its processing demand may be more variable, with peaks during reporting periods or major academic events.</p>
<p>By separating storage from processing, if we focus on systems that can serve to store data in an infrastructure, we might encounter:</p>
<ul>
<li><p><strong>Distributed databases:</strong> These are databases deployed to operate across multiple machines, using technologies like Cassandra or DynamoDB.</p>
</li>
<li><p><strong>Object Storage:</strong> These systems are dedicated to storing large volumes of data in independent objects, utilizing Amazon S3, Azure Blob Storage, Google Cloud Storage, or MinIO.</p>
</li>
<li><p><strong>Search engines:</strong> These systems specialize in quickly indexing and querying logs, texts, and other types of semi-structured information with technologies like Elasticsearch or OpenSearch.</p>
</li>
<li><p><strong>Distributed file systems:</strong> These store and distribute files across multiple machines using HDFS or CephFS.</p>
</li>
</ul>
<p>On the other hand, data processing in an infrastructure can be distinguished based on the approach taken, which depends on volume and speed:</p>
<ul>
<li><p><strong>Batch processing:</strong> Here, data is accumulated over time and periodically processed in batches. This can be implemented with Apache Spark, for example, which allows tasks like transformation and cleaning to be distributed across multiple machines.</p>
</li>
<li><p><strong>Streaming processing:</strong> Here, all data generated or arriving at the start of a pipeline is processed continuously, making it suitable when real-time results are needed. Technologies used in this case can be Apache Flink or Spark Structured Streaming.</p>
</li>
<li><p><strong>Distributed query and processing:</strong> This allows for the analysis of large volumes of data by executing operations in parallel across multiple machines. One of the most common interfaces is SQL, used by tools like Trino or Spark SQL. But in addition to SQL, these systems often offer APIs in languages like Python, Java, or Scala and abstractions like DataFrames, providing greater flexibility for implementing complex transformations or custom logic.</p>
</li>
</ul>
<p>As an example of architecture, the university could use Kafka to receive events generated by the virtual campus or the transportation company, while Flink could process them to keep the status of each journey updated in real time in the application consulted by the end user. Then, with Spark, they would be transformed to be integrated into a Data Warehouse and queried using SQL.</p>
<p>In practice, the central role that implements and optimizes these storage and processing systems is the <strong>Big Data Engineer</strong> or specialized Data Engineer. For this, they use technologies like Cassandra, Amazon S3, or HDFS, decide how to implement jobs using Spark, and ensure adequate performance.</p>
<p>On the other hand, <strong>Data Platform Engineers</strong>, <strong>Cloud Engineers</strong>, and <strong>SREs</strong> handle the base infrastructure, ensuring its stability, availability, and resilience.</p>
<h3 id="heading-big-data-analytics">Big Data Analytics</h3>
<p>In Big Data, besides storing a large volume of diverse data and processing it at a speed that often needs to be high and in real-time, it must be converted into information, knowledge, and ultimately value. This means that processing refers to the transformations performed on the data to enable storage, clean it, or maintain its quality, primarily.</p>
<p>But processing is also applied after storage to calculate statistics and generally analyze the data. This is the role of <a href="https://www.ibm.com/think/topics/big-data-analytics"><strong>Big Data Analytics</strong></a>, an area dedicated to converting data into information, knowledge, and value through analytical processes applied to large volumes of data.</p>
<p>An analysis belongs in a Big Data context when the workload's scale or flow characteristics require distributed or otherwise specialized infrastructure to meet its targets. It doesn't need advanced Machine Learning, and using a scalable cloud platform by itself doesn't make a small analysis "Big Data."</p>
<p>Based on this technological foundation, there are several fundamental analytical approaches you can use, depending on the analysis you need to perform:</p>
<ul>
<li><p><strong>Descriptive Analytics:</strong> Focuses on applying techniques that explore data to understand what has happened. For example, it allows calculating how many trips have been made, how much they have cost, and how many students have used the service each month.</p>
</li>
<li><p><strong>Diagnostic Analytics:</strong> Here, the analyses aim to understand why a result has occurred. At the university, it could be used to study which supplier time slots are related to an increase in transportation service costs.</p>
</li>
<li><p><strong>Predictive Analytics:</strong> Uses historical data to make inferences and try to predict what will happen in the future. For example, it could predict how many enrollment applications will be received next term.</p>
</li>
<li><p><strong>Prescriptive Analytics:</strong> Turns the results of analyses into recommendations. For instance, in this case, it could suggest how to optimize the distribution of taxi fleets and reallocate the monthly budget to ensure service coverage for the maximum number of students.</p>
</li>
</ul>
<p>In big data environments, analysis can be executed in <strong>batch</strong> or <strong>streaming</strong>, depending on each process's requirements. For instance, with Apache Spark, you could periodically calculate the evolution of taxi trip costs and class attendance, while with Flink, real-time trips could be analyzed to generate alerts if demand exceeds a certain amount.</p>
<p>For analysis processes to be truly useful, they begin by defining the question to be answered with the obtained knowledge and the value expected to be added, meaning the decision to be made with the result. Then, the necessary data is selected and prepared, ensuring its quality is adequate for analysis. After execution, the result is published via a dashboard, report, alert, API, or predictive model.</p>
<p>It's also important to note that having a larger volume of data doesn't always guarantee "better" conclusions or more value. For example, if students using the taxi service have higher attendance, you can't directly conclude that transportation is the cause, as those students might be taking more in-person classes or have other differences.</p>
<p>So besides handling a large volume of information, it's crucial to interpret results correctly. In this specific case, the problem is that correlation doesn't always imply causation in the analyzed facts, but this isn't the only issue that can arise in an analysis.</p>
<p><strong>Data Engineers</strong> build and maintain pipelines and analytical environments. <strong>Data Analysts</strong> use SQL, Trino, Spark SQL, Power BI, Tableau, and similar tools for a range of analyses, often descriptive and diagnostic. <strong>Data Scientists</strong> use Python, R, Jupyter, Spark, or MLlib for statistical modeling, experimentation, prediction, and optimization.</p>
<p><strong>BI Developers</strong> turn governed metrics and analyses into reports and dashboards. <strong>Machine Learning Engineers</strong> help train, deploy, and operate models. Domain experts, Data Owners, and Data Stewards help teams interpret and use the results responsibly.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/OrORtZ6rnJo" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h2 id="heading-analytics-and-data-science">Analytics and Data Science</h2>
<p>Organizations analyze data to understand what's happening, support decisions, test ideas, and build models. This is one of the main ways they turn data into knowledge and value.</p>
<p><a href="https://docs.cloud.google.com/docs/data"><strong>Analytics</strong></a> and <a href="https://aws.amazon.com/what-is/data-science/"><strong>Data Science</strong></a> are overlapping, complementary fields. Analytics often focuses on answering defined questions with descriptive, diagnostic, predictive, or prescriptive methods. At the university, an analyst might study attendance over the past month and investigate which changes coincide with a decline.</p>
<p>Data Science often tackles less-defined or model-heavy questions through <strong>statistics</strong>, <strong>Machine Learning</strong>, computation, and domain knowledge. It may explain patterns, estimate effects, segment observations, or make predictions. The university could use it to forecast transportation demand over the next six months.</p>
<p>In practice, <a href="https://www.tableau.com/analytics/data-science-vs-data-analytics"><strong>both use data to achieve a goal</strong></a>, and the exact boundary varies by organization. Both need governed, suitable, high-quality data and a clear understanding of the decision their result will support.</p>
<p>An analysis should start with a clear question. The university might ask whether the transportation benefit improves class attendance or how many rides students will request next week. The first needs a careful causal design, while the second calls for a forecasting or predictive model.</p>
<p>After formulating the question, a process is established that covers everything from the question to a final analytical product like a dashboard, report, or simply the knowledge produced that contributes to decision-making.</p>
<p>In this process, an <strong>analytical dataset</strong> is generally built to serve as a source for subsequent analysis. Then, this dataset is explored to understand the data, model it mathematically, or perform transformations on it. In other words, the analysis process begins by applying techniques suited to the business question's needs.</p>
<p>Finally, if you need to train a machine learning model, you'll make certain transformations to prepare the dataset for training, so it's considered <strong>model-ready</strong>. After training, results are delivered through a report, API, or by deploying the model in the infrastructure to make predictions, for example.</p>
<p>In this process, various roles collaborate, such as <strong>Data Analysts</strong>, who answer business questions related to <strong>Analytics</strong>, while <strong>Data Scientists</strong> formulate hypotheses and develop models to describe data or make predictions. <strong>Analytics Engineers</strong> focus on building analytical datasets, and <strong>Data Engineers</strong> construct the pipelines and infrastructure that supply them.</p>
<p>Also, when a machine learning model needs to be integrated into an application, <strong>Machine Learning Engineers</strong> are involved.</p>
<h3 id="heading-analytical-datasets">Analytical Datasets</h3>
<p>An <strong>analytical dataset</strong> is prepared for a defined analysis. It isn't a random collection of files: it has a known schema, grain, population, time period, quality criteria, and lineage. The team selects data because it is relevant to the question rather than including every available field.</p>
<p>In the university use case, to study if taxi service improves attendance, a dataset could be built with records of trips and class attendance of students who have or haven't traveled, allowing for a comparison of their attendance statistics.</p>
<p>On the other hand, to predict transportation demand, it would be more appropriate to build another dataset that integrates travel history with class schedules, the academic calendar, or weather conditions. Thus, although both sets may reuse some data sources, their structure, granularity, and quality rules would differ, as each must be designed to address the specific business question.</p>
<p>The design of how a dataset should be is the responsibility of a <strong>Data Analyst</strong> or <strong>Data Scientist</strong>, while the implementation of transformations and other processes necessary for its construction is carried out by <strong>Analytics Engineers</strong>. But if data from multiple sources need to be integrated, a <strong>Data Engineer</strong> handles this task, as we have seen.</p>
<p>These datasets are usually materialized in the form of tables in a Data Warehouse, Data Lake, or as column-oriented files like <strong>Apache Parquet</strong>.</p>
<h3 id="heading-exploratory-data-analysis">Exploratory Data Analysis</h3>
<p>Most analyses include <a href="https://youtu.be/QiqZliDXCCg?si=FFey4JEGFIx2cjWG"><strong>Exploratory Data Analysis</strong></a> <em><strong>(EDA)</strong></em> because you rarely understand a new dataset perfectly at the start.</p>
<p>EDA examines the dataset's distributions, patterns, relationships, and unusual values before the team draws conclusions or builds a model. It also reviews types, missing values, duplicates, quality limitations, and possible sources of bias.</p>
<p>Regarding exploration techniques, <a href="https://youtu.be/FzujIYo9GYo?si=n6yNvrW_g_Qi4L4W"><strong>descriptive statistics</strong></a> and the creation of <strong>visualizations</strong> are usually key. For example, a Data Analyst might represent the number of enrollments paid per day, compare the payment methods used, and analyze when more incidents occur. This way, they could discover if any of the payment platforms or banks involved in the transactions have caused problems with enrollment payments at any point.</p>
<p>They might also observe phenomena such as students who pay earlier achieving better academic results, but that correlation wouldn't prove that paying in advance is the main cause. Still, exploration serves to generate this hypothesis and detect possible alternative explanations, but not to confirm a causal relationship on its own.</p>
<p>EDA is performed by both <strong>Data Analysts</strong> and <strong>Data Scientists</strong>, though in different ways, as analysts seek to make diagnoses, while scientists explore the data to decide how to model it.</p>
<p>The technologies they use for exploration are very diverse, from SQL for querying the dataset, Jupyter notebooks for more easily documenting Python code, to Python libraries like pandas, NumPy, SciPy, Matplotlib, and Seaborn. Other languages that also allow data exploration include R, Julia, or Scala.</p>
<h3 id="heading-feature-engineering">Feature Engineering</h3>
<p>After exploring the data, transformations are often applied to make them more useful depending on the intended purpose. If we view the data as a set of records where each takes values in a series of attributes called <strong>features</strong>, sometimes these features may be more or less useful for training a machine learning model or simply for understanding the data.</p>
<p>For example, if we have student records in the form <strong>(name, email, 1)</strong>, having a feature with a fixed value of 1 doesn't contribute to an analysis unless it's a relevant feature that always takes the value 1 for some realistic reason. In this case, it would be ideal to remove the feature and keep only the most useful ones.</p>
<p><a href="https://youtu.be/Bg3CjiJ67Cc?si=mjds_k4Lr5jrKGJc"><strong>Feature Engineering</strong></a> transforms or derives model inputs so they represent the problem usefully. Techniques include <strong>normalization</strong> or standardization for scale-sensitive algorithms, <a href="https://en.wikipedia.org/wiki/Imputation_(statistics)"><strong>data imputation</strong></a> for suitable missing values, encoding categories, and discretization. Each choice should follow the business meaning, model type, and evaluation plan rather than a fixed recipe.</p>
<p>For example, imagine the university wants to predict whether a student will finish the master's program. To do this, they have an analytical dataset with records whose features include class attendance, grades, and the number of accesses to the virtual campus, which will later be used to train a machine learning model for prediction.</p>
<p>For a model that is sensitive to feature scale, <a href="https://youtu.be/bqhQ2LWBheQ?si=FyajXf7Y4ieKhxDY"><strong>normalization</strong></a> may help because grades range from 0 to 10 while portal-access counts can reach thousands. Min-max scaling can map them to <strong>[0, 1]</strong>, although other algorithms or scaling methods may be more suitable.</p>
<p>The team must also exclude information that wouldn't be available at prediction time. If a feature reveals the outcome directly or indirectly, <a href="https://www.ibm.com/think/topics/data-leakage-machine-learning"><strong>data leakage</strong></a> can make evaluation look unrealistically good.</p>
<p>These transformations are usually performed by a <strong>Data Scientist</strong>, <strong>Analytics Engineers</strong>, <strong>Data Engineers</strong>, or a <strong>Machine Learning Engineer</strong>, primarily. All these roles use technologies like SQL, Apache Spark, or Python to perform them, though these aren't the only ones.</p>
<h3 id="heading-experimentation">Experimentation</h3>
<p>Many analyses test a <strong>hypothesis</strong>. If the team believes a feature doesn't improve a model, it can state that idea clearly and use <a href="https://youtu.be/arWJoWPpOqY?si=6PriCYitgQCUvDOE"><strong>experiments</strong></a> to compare a model trained with and without the feature.</p>
<p><a href="https://youtu.be/YpZ7Gb9d-Lc?si=BKEzubCWulgTwI0j"><strong>Experimentation</strong></a> changes controlled parts of a dataset, method, or training process to test a hypothesis. The work is iterative: one result can reject the original idea or suggest a better question for the next experiment.</p>
<p>In this field, it's important to distinguish between two types of experimentation with different purposes. First, there's <a href="https://youtu.be/vIFKGFl1Cn8?si=5NuZmDa__PNrnW4R"><strong>analytical experimentation</strong></a>, which is conducted on already collected data and focuses on comparing features, types of models, and training techniques to determine which combination of these elements best answers the business question.</p>
<p>For example, to predict if a student will complete their master's program, the university might start with a simple model using only grades and attendance. Then, they could run another experiment incorporating the number of virtual campus logins or try a different algorithm.</p>
<p>This way, they could determine if the change truly enhances predictive capability or merely increases model complexity.</p>
<p>Also, this model should be evaluated with data not used in its training. Otherwise, it might "cheat," performing well with training data but failing to "generalize" and achieve the same performance with real data.</p>
<p><a href="https://youtu.be/DUNk4GPZ9bw?si=ZZBhm12bp-ssxkSM"><strong>Controlled experiments</strong></a> introduce a change and compare outcomes between a <strong>treatment group</strong> that receives it and a <strong>control group</strong> that doesn't. Random assignment, when feasible and ethical, helps make the groups comparable.</p>
<p>For instance, to see if a taxi service improves attendance, the university could gradually introduce it to a small group of students, provided it's ethically and legally appropriate. Here, the hypothesis would be that the service improves attendance, tested by analyzing treatment data from students who received the service against control data from those who didn't, using metrics like the percentage of classes attended.</p>
<p>Experiments should be reproducible. Teams can version code and configuration with Git, while platforms like MLflow record runs, parameters, metrics, and artifacts.</p>
<p>Data Scientists usually formulate hypotheses and design model experiments with Data Analysts and domain experts. Machine Learning Engineers may help make the training and evaluation workflow reliable at production scale.</p>
<h3 id="heading-model-ready-data">Model-Ready Data</h3>
<p>Analytical datasets are often ready for analysis but this isn't always the case. If your goal is to train a machine learning model to make predictions, then the dataset must meet additional conditions.</p>
<p>To train a model, the data needs to be <a href="https://www.ibm.com/think/topics/ai-ready-data"><strong>model-ready</strong></a>: prepared for the selected algorithm, evaluation design, and production use. A supervised-learning dataset needs a target variable that records the outcome to learn. Unsupervised methods can work without labels, so model-ready requirements depend on the task.</p>
<p>For example, to predict whether a student will leave a master's program, historical training records need an outcome label such as <strong>(student_reference, enrolled_subjects, withdrew)</strong>. The team should exclude direct identifiers such as names from model features unless there's a justified need, and it must review whether the proposed prediction is fair and appropriate to use.</p>
<p>The team also separates data for training, validation, and final testing as the evaluation design requires. It develops the model without using the held-out <strong>test</strong> data for decisions, then uses that test set for an honest estimate of performance on unseen cases. For time-based predictions, the split should also respect chronology.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/dSCFk168vmo" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<p>Finally, model-ready also implies that the data is <strong>representative</strong> of the target concept we want the model to "learn." For example, if we train a model to predict master's program dropout using only data from those who have dropped out, it likely won't learn the patterns indicating when someone doesn't drop out, making the dataset unrepresentative.</p>
<p>Thus, ensuring datasets are model-ready is the responsibility of <strong>Data Engineers</strong>, <strong>Data Scientists</strong>, and <strong>Machine Learning Engineers</strong> who may use them.</p>
<h3 id="heading-analytical-product-delivery">Analytical Product Delivery</h3>
<p>Analysis creates value only when its results reach the right people or systems in a usable form. If the university uses a model to identify unusual exam activity, for example, it should treat the output as a signal for authorized human review rather than proof of misconduct.</p>
<p><strong>Analytical Product Delivery</strong> provides the right consumption channel for each result. That channel might be a report, dashboard, alert, file, API, or prediction embedded in an application.</p>
<p>For instance, the university could deliver attendance analysis through a report or dashboard. A carefully governed model that estimates withdrawal risk might provide limited alerts through an internal API to an authorized support team, which would review the context before offering help. The channel and controls should match the intended use and potential impact.</p>
<p>Relevant practices in <strong>Analytical Product Delivery</strong> include defining the consumers of the results, their update frequency, and quality metrics. All this is documented along with data sources and other aspects, and the delivery mechanisms are monitored.</p>
<p>In the example, a dashboard with attendance analysis would have the rectorate and master's coordinators as consumers, updating with new data monthly. Meanwhile, the dropout prediction model would deliver its alerts to an academic officer via an API, even if this officer accesses it with an application.</p>
<p>The <strong>delivery</strong> is coordinated by the <strong>Data Product Owner</strong> or <strong>Product Manager</strong>, while technical teams with professionals like <strong>Analytics Engineers</strong>, <strong>Software Engineers</strong>, or <strong>BI Developers</strong> are responsible for implementing all the result delivery mechanisms.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/PSNXoAs2FtQ" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/CMEWVn1uZpQ" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h2 id="heading-data-products">Data Products</h2>
<p>An analytical result isn't automatically a product. A <strong>Data Product</strong> packages governed data with a way for defined consumers to use it and an operating model that keeps it useful over time.</p>
<p>It may take the form of a dataset, API, dashboard, or another interface. A dashboard or file alone isn't necessarily a Data Product: it needs a clear purpose, known consumers, ownership, documentation, and defined quality and service expectations.</p>
<p>In our focused use case, the university could create a <strong>Mobility Eligibility</strong> Data Product. It would combine only the approved enrollment, in-person schedule, distance, and eligibility attributes needed for the transportation benefit. An API could return an eligibility decision and its effective date to the student portal, while a separate governed dataset could provide aggregated service metrics.</p>
<p>Keeping this product narrow avoids exposing a complete student profile to consumers that don't need it.</p>
<h3 id="heading-product-characteristics">Product Characteristics</h3>
<p>In this context, managing a Data Product should be done just like a commercial product, hence the need to define its consumers and those responsible, and to ensure its quality and availability.</p>
<p>But in the realm of data, there are certain fundamental characteristics for any product:</p>
<ul>
<li><p><strong>Discoverable:</strong> It must be accessible through a data catalog or the appropriate tool.</p>
</li>
<li><p><strong>Understandable:</strong> The data schema, its semantics, and all aspects that facilitate its comprehension and traceability, such as lineage, must be documented.</p>
</li>
<li><p><strong>Reliable:</strong> Quality and availability are measured against clear expectations, with monitoring and a response process when the product misses them.</p>
</li>
<li><p><strong>Secure:</strong> Access controls are implemented, and the exposure of personal data is minimized.</p>
</li>
<li><p><strong>Interoperable:</strong> The data should be able to be integrated and function correctly in other systems.</p>
</li>
<li><p><strong>Stable:</strong> This means the data shouldn't undergo frequent changes in its schema, properties, or consumption methods.</p>
</li>
</ul>
<p>A <strong>Data Contract</strong> can formalize important parts of the product interface, such as schema, semantics, quality rules, and update frequency. The product also needs documentation for ownership, access, support, lifecycle, and consumer expectations.</p>
<h3 id="heading-ownership-and-lifecycle">Ownership and Lifecycle</h3>
<p>No single role builds a Data Product alone. The <strong>Data Product Owner</strong> works with consumers, defines requirements, and sets objectives based on expected value.</p>
<p>On a technical level, there are Data Engineers, Analytics Engineers, or Platform Engineers, among others, who operate the infrastructure for storing and analyzing data, generating the results that become a product.</p>
<p>The lifecycle includes identifying consumer needs, defining the product and its contract, building and releasing it, monitoring service and data quality, improving it, and eventually retiring it.</p>
<p>Adoption is one sign of success, but it isn't enough by itself. The product should help consumers achieve a valuable outcome while maintaining quality, availability, security, and sustainable operating cost.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/7w7_QWPS9L8" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h2 id="heading-data-management-organization">Data Management Organization</h2>
<p>We've covered many capabilities, technologies, and roles. The <strong>Data Management Organization</strong> defines how these people work together, make decisions, and resolve issues across the lifecycle.</p>
<p>Its operating model assigns authority and responsibility, sets forums and workflows, and gives teams a consistent way to resolve problems and deliver value.</p>
<h3 id="heading-operating-model">Operating Model</h3>
<p>An <a href="https://www.snowflake.com/en/data-governance/models/"><strong>operating model</strong></a> organizes decision-making and delivery. In a <strong>centralized model</strong>, one data team handles most of the work. This can improve consistency, but the team may become distant from domain knowledge or turn into a bottleneck.</p>
<p>Another type of operating model is <strong>decentralized</strong>, where each department or area of the organization manages the data within its domain, increasing autonomy but at the cost of a higher risk of inconsistencies and data silos, making global decision-making more difficult.</p>
<p>Data silos refer to sets of information isolated within an area or system, making them inaccessible or very difficult to reach for the rest of the organization.</p>
<p>Many organizations use a <strong>hybrid or federated</strong> model, which seeks to combine the advantages of both approaches. Here, each domain maintains a certain degree of autonomy over its data and is responsible for its quality, documentation, and use, while a central unit establishes governance principles, standards, and policies that must be respected throughout the organization.</p>
<p>For example, a hybrid organizational model at a university could have a central <strong>Data Management Office</strong> led by the CDO, while different domains like Academic Activity, Finance, or Mobility would have their own Data Owners, Data Stewards, and technical teams. If multiple domains need to collaborate, a <strong>Data Governance Council</strong> could assist in decision-making related to this collaboration.</p>
<h3 id="heading-roles-and-collaboration">Roles and Collaboration</h3>
<p>The main roles in this context have already been mentioned. But regarding collaboration among them, it's crucial that their responsibilities are clearly defined and documented. This can be formalized through documentation, tools like a <strong>RACI matrix</strong>, Data Contracts, Governance Charters, or by setting up <strong>workflows</strong>.</p>
<p>For proper coordination, technologies like Git repositories are used to collaboratively version their work, data catalogs, platforms similar to Jira for communication, and observability tools. But technology doesn't replace the need for authority, communication, and clear responsibilities.</p>
<h2 id="heading-data-management-maturity">Data Management Maturity</h2>
<p>Organizations differ in how consistently they apply these capabilities. <strong>Data Management Maturity</strong> describes how well practices are embedded, measured, governed, and aligned with organizational goals.</p>
<p>For example, an organization with low maturity would manage data with isolated and ad-hoc actions based on arising needs. As maturity increases, processes and management practices begin to be documented to become standardized, governed, and properly automated. At the highest levels of maturity, a managed approach is adopted, where the management strategy is controlled through quality metrics, audits, and formal risk management.</p>
<p>Maturity focuses not only on the technical aspect but also on the ability to coordinate personnel, their responsibilities, and the tools they use to achieve sustainable results aligned with the organization's strategy.</p>
<h3 id="heading-maturity-levels">Maturity Levels</h3>
<p>One illustrative maturity model uses the following levels:</p>
<ul>
<li><p><strong>Level 0 – No Capability:</strong> There are no organized practices for managing data. Actions are taken as deemed appropriate at the moment.</p>
</li>
<li><p><strong>Level 1 – Initial:</strong> Management is assigned to specific professionals, but there's no control over individual actions or collaboration methods.</p>
</li>
<li><p><strong>Level 2 – Managed:</strong> Processes, roles, and tools begin to be documented to facilitate the replication and automation of management tasks.</p>
</li>
<li><p><strong>Level 3 – Defined:</strong> Policies and standards are formalized and unified across the organization, ensuring all teams work in a coordinated and scalable manner.</p>
</li>
<li><p><strong>Level 4 – Measured:</strong> Management is controlled more deeply through audits and metrics to evaluate performance and actively mitigate risks.</p>
</li>
<li><p><strong>Level 5 – Optimized:</strong> Teams use measurements, feedback, and appropriate automation to improve management continuously and reduce problems before they affect consumers.</p>
</li>
</ul>
<h3 id="heading-assessment-and-roadmap">Assessment and Roadmap</h3>
<p>To determine the maturity level and enhance it within your organization, your team can use a <strong>Data Management Maturity Assessment</strong>.</p>
<p>This process begins by defining which data domains and management capabilities are to be evaluated. Evidence is then gathered to analyze the maturity level achieved with these capabilities, examining what is documented, which policies are followed, and so on.</p>
<p>By comparing with a target maturity level, a <strong>roadmap</strong> is developed to reach it, with steps that can vary significantly depending on the specific organization and its current level.</p>
<p>This process is led by the <strong>CDO</strong> or the <strong>Data Governance Office</strong>, with participation from <strong>Data Owners</strong>, <strong>Data Stewards</strong>, and technical teams.</p>
<p>For example, at the university, the <strong>Mobility</strong> domain would be at level 1 if student eligibility for the service were reviewed manually and depended on specific individuals' knowledge. At level 2, responsibilities would be assigned, documentation on the concept of eligibility would begin, and basic validations would be automated.</p>
<p>At level 3, Data Products could unify access to selected mobility information under shared rules. At level 4, dashboards could track quality, availability, usage, cost, fairness, and incidents. At level 5, teams would automate low-risk work where appropriate, keep human review and appeal paths for consequential eligibility decisions, and improve the service continuously through metrics and user feedback.</p>
<p>But the goal doesn't have to be reaching level 5 in all capabilities. The university might require high maturity in security and quality capabilities that protect personal data, while a more experimental analysis of classroom usage that doesn't involve personal data might have a lower target.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/jXQ9TKeVJkE" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h2 id="heading-conclusions">Conclusions</h2>
<img src="https://cdn.hashnode.com/uploads/covers/66b716b04709012ee58fbbdc/8d2f267f-e8aa-4208-9bf8-a789ded088df.png" alt="The Data Management Ecosystem full diagram. Image by author." style="display:block;margin:0 auto" width="1672" height="941" loading="lazy">

<p>Throughout this book, we've treated Data Management as a coordinated set of capabilities that helps an organization capture, integrate, protect, understand, and use data throughout its lifecycle.</p>
<p>The wider university ecosystem shows the scale of a real organization, while our admissions, academic-activity, and transportation examples make the connections concrete. Even a controlled transportation benefit requires much more than a database: it needs governance, quality, privacy, integration, reliable operations, and careful analysis.</p>
<p>Data doesn't generate value automatically. It becomes useful when people give it context, protect it, make it available to the right consumers, and connect it to a real goal. Technology is the means, not the objective. Databases, pipelines, dashboards, models, and Data Products matter only when they solve a genuine need.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Firestore Data Modeling Guide: Embedded Documents vs Referencing (with a Blog Case Study) ]]>
                </title>
                <description>
                    <![CDATA[ When developers transition from the relational world (MySQL, PostgreSQL) to Firestore, Firebase's NoSQL document database, they often bring their old habits with them. They try to replicate tables, fo ]]>
                </description>
                <link>https://www.freecodecamp.org/news/firestore-data-modeling-guide-embedded-documents-vs-referencing-with-a-blog-case-study/</link>
                <guid isPermaLink="false">6a63826ed2f5d140f2aaa325</guid>
                
                    <category>
                        <![CDATA[ firestore ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Firebase ]]>
                    </category>
                
                    <category>
                        <![CDATA[ NoSQL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Databases ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Query ]]>
                    </category>
                
                    <category>
                        <![CDATA[ SQL ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Caleb Mintoumba ]]>
                </dc:creator>
                <pubDate>Fri, 24 Jul 2026 15:19:10 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/f0166ca3-ca48-45f6-bb2f-ee6b20701ea0.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>When developers transition from the relational world (MySQL, PostgreSQL) to Firestore, Firebase's NoSQL document database, they often bring their old habits with them. They try to replicate tables, foreign keys, and joins.</p>
<p>The result? Complex queries, skyrocketing read costs, and a database structure that becomes a nightmare to maintain after just a few features.</p>
<p>To understand how Firestore works, we first need to look at our point of comparison: the relational model. Once we map out how SQL does things, we can see exactly where Firestore diverges, and how to structure NoSQL data correctly.</p>
<p>In this guide, we'll cover NoSQL design principles, embedding vs. referencing, and relationship modeling (1-1, 1-N, N-N). We'll also walk through a concrete blog case study.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-relational-mindset-how-sql-handles-data">The Relational Mindset: How SQL Handles Data</a></p>
</li>
<li><p><a href="#heading-the-firestore-paradigm-nosql-with-relationships">The Firestore Paradigm: NoSQL with Relationships</a></p>
</li>
<li><p><a href="#heading-the-core-building-blocks-documents-and-collections">The Core Building Blocks: Documents and Collections</a></p>
</li>
<li><p><a href="#heading-the-golden-rule-model-for-reads-not-writes">The Golden Rule: Model for Reads, Not Writes</a></p>
</li>
<li><p><a href="#heading-embedding-vs-referencing-denormalization">Embedding vs. Referencing (Denormalization)</a></p>
</li>
<li><p><a href="#heading-how-to-model-relationships-1-1-1-n-n-n">How to Model Relationships (1-1, 1-N, N-N)</a></p>
</li>
<li><p><a href="#heading-best-practices-and-pitfalls-to-avoid">Best Practices and Pitfalls to Avoid</a></p>
</li>
<li><p><a href="#heading-case-study-designing-a-scalable-blog-database">Case Study: Designing a Scalable Blog Database</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This guide is conceptual, so you don't need a running Firestore project to follow along. A little context is enough. You will need:</p>
<ul>
<li><p>Basic JavaScript syntax, since every code example uses the modular Firebase JS SDK (v9+)</p>
</li>
<li><p>Basic familiarity with JSON objects (keys, values, nested objects, arrays)</p>
</li>
<li><p>Some exposure to SQL or relational databases helps, since the guide leans on that comparison throughout (but it's not required)</p>
</li>
<li><p>(Optional) A free Firebase project, if you want to try the examples yourself. The <a href="https://firebase.google.com/docs/firestore/quickstart">Firestore quickstart</a> walks you through setting one up.</p>
</li>
</ul>
<p>No prior NoSQL or Firestore experience is needed.</p>
<h2 id="heading-the-relational-mindset-how-sql-handles-data">The Relational Mindset: How SQL Handles Data</h2>
<p>In a relational database, data is organized into tables linked by explicit relationships. This approach relies on <strong>normalization</strong> to eliminate data redundancy.</p>
<p>For example, to store users and their respective countries, we split the data into two tables:</p>
<ul>
<li><p><code>Users</code>: columns <code>id</code> (PK), <code>last_name</code>, <code>first_name</code>, <code>#country_id</code> (FK a foreign key)</p>
</li>
<li><p><code>Countries</code>: columns <code>country_id</code> (PK), <code>country_name</code></p>
</li>
</ul>
<p>With a row like <code>1, MINTOUMBA, Caleb, 1</code> in <code>Users</code> and <code>1, Canada</code> in <code>Countries</code>, we automatically know that Caleb belongs to Canada through the foreign key <code>#country_id</code>. We never had to write the word "Canada" inside the <code>Users</code> table itself.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66f71ee288cc311f84e563bc/ac10a248-0b5e-4be7-a738-3a6bdd54c1d7.png" alt="Relational model showing a Users table linked to a Countries table through a foreign key" style="display:block;margin:0 auto" width="2179" height="1019" loading="lazy">

<p><strong>The SQL trade-off:</strong> writes are lightweight (you only update data in one place), but reads are heavier, because you have to perform a database join (<code>JOIN</code>) every time you want to display a user's country name.</p>
<p>That's exactly the opposite of how Firestore works, as we'll see next.</p>
<h2 id="heading-the-firestore-paradigm-nosql-with-relationships">The Firestore Paradigm: NoSQL with Relationships</h2>
<p>Firestore is a <strong>NoSQL</strong> document database – literally <em>Not Only SQL</em>. It stores JSON-like documents grouped into collections, with no enforced schema.</p>
<p>For most of Firestore's history, that also meant no native joins and no <code>GROUP BY</code>. The standard query engine simply didn't support them, and any aggregation beyond <code>count()</code>, <code>sum()</code>, and <code>average()</code> had to happen in your application code.</p>
<p>That's still true today for <strong>Standard edition</strong>, which remains the default and the one most mobile/web apps run on and the one this guide focuses on.</p>
<p>Google has since introduced <strong>Firestore Enterprise edition</strong>, built around a new <strong>Pipeline</strong> query engine that reached general availability in April 2026. Pipelines add a multi-stage query syntax and hundreds of new functions, including relational-style joins through correlated subqueries and a real <code>aggregate(...)</code> step with grouping Firestore's equivalent of SQL's <code>GROUP BY</code>.</p>
<p><strong>Does this mean data modeling doesn't matter anymore?</strong> Not for most apps. Pipeline queries run within a 60-second timeout and a 128 MiB working-memory limit, can fall back to full collection scans when no index exists, and critically, Enterprise edition drops real-time listeners and offline support (which most Firestore client apps depend on).</p>
<p>Pipelines are a genuine escape hatch for analytical, admin, or reporting queries. They're not a drop-in replacement for the read-optimized structure your app's everyday screens still need.</p>
<p>If you're building a typical client-facing app on Standard edition, the embedding and denormalization strategies below are still how you model relationships.</p>
<p>But <strong>NoSQL doesn't mean "no relationships"</strong> even on Standard edition. You can and should build robust relationships between your collections. The difference is that Firestore won't enforce or resolve them for you the way a <code>JOIN</code> does by default. It's up to you, the developer, to build and query those relationships explicitly, and to maintain data integrity through your application code or Cloud Functions unless you've specifically opted into Enterprise edition for Pipeline-powered joins.</p>
<h2 id="heading-the-core-building-blocks-documents-and-collections">The Core Building Blocks: Documents and Collections</h2>
<p>Before designing any schema, let's define Firestore's two core building blocks:</p>
<ul>
<li><p><strong>Document</strong>: the basic unit of storage. It's a JSON-like object, identified by a unique ID, containing typed fields (strings, numbers, booleans, timestamps, geopoints, or references to other documents).</p>
</li>
<li><p><strong>Collection</strong>: a container for documents. Unlike SQL tables, documents in the same collection don't need to share the same structure.</p>
</li>
</ul>
<p>What makes Firestore unique is its hierarchical nature: <strong>a document can contain sub-collections</strong>, which contain more documents, which can themselves contain more sub-collections, and so on.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66f71ee288cc311f84e563bc/3d49ff84-8648-4c15-bcc3-60caeb540a77.png" alt="Firestore hierarchy diagram showing a posts collection containing the post_001 document, which holds a comments sub-collection with individual comment documents" style="display:block;margin:0 auto" width="2179" height="1259" loading="lazy">

<p>In the diagram above, the root <code>posts</code> collection contains the document <code>post_001</code>, which itself hosts a <code>comments</code> sub-collection containing the individual comment documents <code>comment_001</code> and <code>comment_002</code>. You can nest collections and documents several levels deep, but as we'll see later, it's best to do so sparingly.</p>
<p><strong>Crucial rule:</strong> sub-collections are never retrieved automatically when you read a parent document. Unlike a SQL <code>JOIN</code>, you must always perform a separate, explicit query to read a sub-collection.</p>
<h2 id="heading-the-golden-rule-model-for-reads-not-writes">The Golden Rule: Model for Reads, Not Writes</h2>
<p>This is the single most important concept in NoSQL modeling, and the one developers coming from SQL forget most often: <strong>structure your data based on how your app queries it, not on how it gets written.</strong></p>
<p>Before writing any database code, ask yourself:</p>
<ul>
<li><p>Which screens in my app will display this data?</p>
</li>
<li><p>Do I need this piece of data on its own, or always alongside another one?</p>
</li>
<li><p>Do I read this information significantly more often than I write or update it?</p>
</li>
</ul>
<p>If your users view a writer's profile 10,000 times for every single time that writer updates their username, optimize for the reads: duplicate the username directly inside each post. That's the exact opposite of the SQL instinct we saw earlier, where you normalize first to avoid redundancy, even if it makes reads heavier.</p>
<h2 id="heading-embedding-vs-referencing-denormalization">Embedding vs. Referencing (Denormalization)</h2>
<p>There are two primary strategies for representing a relationship in Firestore.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66f71ee288cc311f84e563bc/ac6d31d1-9c75-4688-b39a-e01ffa55ea07.png" alt="Side-by-side comparison of embedding comments directly inside a post document versus referencing them through a separate comments sub-collection" style="display:block;margin:0 auto" width="2179" height="1180" loading="lazy">

<h3 id="heading-option-a-embedding-nesting">Option A: Embedding (Nesting)</h3>
<p>You store the related data directly inside the parent document, as an array or a map (object).</p>
<pre><code class="language-js">// A post with its comments embedded
{
  title: "Introduction to Firestore",
  author: "Caleb",
  comments: [
    { user: "Ama", text: "Great post!" },
    { user: "Kofi", text: "Thanks for the examples" }
  ]
}
</code></pre>
<ul>
<li><p><strong>Pros</strong>: a single read retrieves everything, and consistency is guaranteed.</p>
</li>
<li><p><strong>Cons</strong>: Firestore documents have a hard <strong>1 MB size limit</strong>. If the nested list grows indefinitely (comments on a viral post, for instance), your writes will start failing once you hit that limit and every write to the parent document also re-sends the whole document to any client listening in real time.</p>
</li>
<li><p><strong>Best for</strong>: small, bounded lists (tags on an article, a user's settings, a short list of favorites).</p>
</li>
</ul>
<h3 id="heading-option-b-referencing-denormalization">Option B: Referencing (Denormalization)</h3>
<p>You split the entities into separate collections or sub-collections, and deliberately duplicate a few fields to avoid a second read.</p>
<pre><code class="language-js">// posts/post_001
{
  title: "Introduction to Firestore",
  authorId: "uid_123",
  authorName: "Caleb",      // denormalized: avoids a second read to "users"
  authorAvatar: "https://...",
  commentCount: 12          // denormalized counter
}

// posts/post_001/comments/comment_001
{
  userId: "uid_456",
  userName: "Ama",
  text: "Great post!",
  createdAt: Timestamp
}
</code></pre>
<p>Here, we duplicate the author's name and avatar into every post so we don't need an extra read to <code>users</code> every time the post list is displayed.</p>
<p>That's denormalization: we accept controlled redundancy in exchange for faster reads the exact opposite of SQL normalization. The cost is that these copies need updating if the user changes their name (usually handled by a Cloud Function triggered when the <code>users</code> document is updated).</p>
<ul>
<li><p><strong>Pros</strong>: no document size limits, and entities can be queried independently.</p>
</li>
<li><p><strong>Cons</strong>: requires multiple reads if you didn't denormalize enough. If a duplicated value changes, you need code (often a Cloud Function) to propagate the update everywhere it's copied.</p>
</li>
<li><p><strong>Best for</strong>: dynamic, fast-growing data (comments, order history, activity logs).</p>
</li>
</ul>
<p><strong>A more precise rule of thumb</strong>: whether to <em>reference instead of embed</em> depends on volume. Sub-collections handle unbounded growth (comments, order history) better than arrays.</p>
<p>Whether to <em>denormalize a given field</em> depends on the cost of keeping it in sync, not how often it changes: a counter you update in place with an atomic increment (<code>commentCount</code>, <code>likeCount</code>) has no other copy to synchronize, so it's cheap to denormalize regardless of frequency.</p>
<p>A copied value like <code>authorName</code>, on the other hand, is duplicated across every document that references it. It's safe to denormalize only if it changes rarely, since any change means propagating the update everywhere it's been copied.</p>
<h2 id="heading-how-to-model-relationships-1-1-1-n-n-n">How to Model Relationships (1-1, 1-N, N-N)</h2>
<h3 id="heading-one-to-one-1-1">One-to-One (1-1)</h3>
<p>Either embed the fields in the same document, or store them in a separate collection using the exact same document ID, for example <code>users/uid_123</code> and <code>privateProfiles/uid_123</code>. This is perfect for separating public data from sensitive data that needs different security rules.</p>
<h3 id="heading-one-to-many-1-n">One-to-Many (1-N)</h3>
<img src="https://cdn.hashnode.com/uploads/covers/66f71ee288cc311f84e563bc/cc6057e0-f3c9-42dd-827b-4546033b6248.png" alt="One-to-many relationship diagram showing a post document linked to multiple comment documents through a sub-collection" style="display:block;margin:0 auto" width="2179" height="980" loading="lazy">

<p>There are three main options, depending on volume and query direction:</p>
<ol>
<li><p>A <strong>sub-collection</strong> (<code>posts/post_001/comments/*</code>) is ideal when you almost always query comments <em>through</em> their parent post, and volume can be large.</p>
</li>
<li><p>A <strong>root collection with a reference</strong> (<code>comments</code> with a <code>postId</code> field) is useful if you also need to query all comments by a given user, independently of the post (<code>where("userId", "==", uid)</code>).</p>
</li>
<li><p>Use an <strong>embedded array</strong> only if the volume stays small and bounded (see Option A above).</p>
</li>
</ol>
<h3 id="heading-many-to-many-n-n">Many-to-Many (N-N)</h3>
<p>This is the trickiest one in NoSQL, since there's no automatic join table like in SQL. There are three common patterns:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66f71ee288cc311f84e563bc/b8b96fab-5180-43fb-98fd-fe16faac162f.png" alt="Many-to-many relationship diagram showing a memberships junction collection linking users and groups" style="display:block;margin:0 auto" width="2179" height="1060" loading="lazy">

<p><strong>(1). Junction collection</strong> the equivalent of a SQL pivot table:</p>
<pre><code class="language-js">// memberships/{membershipId}
{
  userId: "uid_123",
  groupId: "group_789",
  role: "admin",
  joinedAt: Timestamp
}
</code></pre>
<p>You can then query <code>.where("userId", "==", uid)</code> to find all groups a user belongs to, or <code>.where("groupId", "==", gid)</code> to find all members of a group.</p>
<p><strong>(2). ID arrays on both sides</strong> (cross-denormalization):</p>
<pre><code class="language-js">// users/uid_123      -&gt; groupIds: ["group_789", "group_456"]
// groups/group_789   -&gt; memberIds: ["uid_123", "uid_456"]
</code></pre>
<p>Fast to read from either side, but reserve this for lists that stay small the 1 MB document limit and the cost of atomically updating long arrays both work against you at scale.</p>
<p><strong>(3). Hybrid approach</strong>, which is the most common pattern in practice: an array for a lightweight relationship rarely queried from the other side (a user's favorite posts), and a junction collection for a relationship queried frequently in both directions and prone to frequent changes (team memberships).</p>
<h2 id="heading-best-practices-and-pitfalls-to-avoid">Best Practices and Pitfalls to Avoid</h2>
<ul>
<li><p><strong>Limit nesting depth:</strong> Firestore allows sub-collections to be nested indefinitely, but beyond two or three levels, your queries and security rules become genuinely hard to maintain. Prefer flattening the structure with references when you can.</p>
</li>
<li><p><strong>Avoid auto-incremented document IDs:</strong> Sequential IDs (<code>user_1</code>, <code>user_2</code>, <code>user_3</code>...) can cause <em>hotspotting</em>: writes pile up on a narrow range of the index, which degrades performance at scale. Let Firestore generate random, evenly distributed IDs unless you have a specific reason not to.</p>
</li>
<li><p><strong>Watch out for composite indexes:</strong> Any query combining multiple <code>.where()</code> filters, or a <code>.where()</code> with an <code>.orderBy()</code> on a different field, requires a composite index. Plan for these during design rather than discovering them in production (Firestore's error messages include a direct link to auto-generate the missing index).</p>
</li>
<li><p><strong>Mind the write rate on "hot" documents:</strong> The recommended maximum <em>sustained</em> write rate to a single document is about <strong>1 write per second</strong>. A document updated very frequently by many different users a global like counter, for example becomes a bottleneck well before that. Firestore can absorb short bursts (5, 10, even 50 writes in one second) by queuing them, but sustained traffic above ~1 write/sec will start producing contention errors. The standard fix is a <em>sharded counter</em>: split the count across several sub-documents and sum them at read time.</p>
</li>
<li><p><strong>Use sub-collections deliberately:</strong> They're convenient, but they always require a separate query. If you almost always need the data together, embedding or denormalization will perform better.</p>
</li>
<li><p><strong>Design security rules alongside your data model:</strong> Firestore's security rules (<code>firestore.rules</code>) should be designed at the same time as your schema a poorly thought-out structure usually makes precise rules much harder to write.</p>
</li>
</ul>
<h2 id="heading-case-study-designing-a-scalable-blog-database">Case Study: Designing a Scalable Blog Database</h2>
<p>Let's bring every principle from this guide together with a concrete example: a blog with posts, comments, and likes.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66f71ee288cc311f84e563bc/d774e41d-28d6-4d0a-81cb-a7bd088992d9.png" alt="Complete Firestore schema for a blog application showing the posts, comments sub-collection, and likes collection" style="display:block;margin:0 auto" width="2379" height="1300" loading="lazy">

<pre><code class="language-js">// posts/{postId}
{
  title: "Modeling Firestore",
  slug: "modeling-firestore",
  authorId: "uid_123",
  authorName: "Caleb",         // denormalized: avoids a second read to "users"
  content: "...",
  tags: ["firebase", "nosql"], // embedded: small, bounded list
  commentCount: 3,             // denormalized counter
  likeCount: 47,               // denormalized counter (shard it if traffic is high)
  createdAt: Timestamp
}

// posts/{postId}/comments/{commentId}  → sub-collection: read together with the post
{
  userId: "uid_456",
  userName: "Ama",
  text: "Excellent article",
  createdAt: Timestamp
}

// likes/{likeId}  → root collection + reference
{                    // lets you quickly check if ONE user liked ONE post
  postId: "post_001",
  userId: "uid_456"
}
</code></pre>
<p>Each choice here answers a specific read pattern. Tags are always displayed alongside the post, so they're embedded. Comments can grow large in number and are almost always fetched together with their parent post, so they live in a sub-collection. Likes need to be queried both by post <em>and</em> by user to check whether <em>this</em> user already liked <em>this</em> post so they sit in a root collection with two indexable fields.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In SQL, you normalize to eliminate redundancy, and you pay for that choice at read time, via joins. In Firestore, it's the opposite: you accept controlled redundancy (denormalization) to make reads instant and cheap, at the cost of slightly heavier writes.</p>
<p>Modeling data in Firestore isn't about applying relational habits with a different syntax. It's a genuinely different way of thinking, centered on your app's read patterns.</p>
<p>Always ask "how will I read this data, and how often?" before choosing between embedding, referencing, or a sub-collection. Also, keep Firestore's concrete limits in mind (1 MB per document, composite indexes, hotspotting) from the design phase rather than discovering them in production.</p>
<p>That balance between read simplicity and write cost is what separates a Firestore database that scales gracefully from one you'll be rewriting six months from now.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Optimize Enterprise Application Performance with T-SQL Query Tuning and Indexing Strategies ]]>
                </title>
                <description>
                    <![CDATA[ In this article, you'll learn how to optimize SQL Server performance using T-SQL query tuning, indexing strategies, execution plans, and real-world optimization techniques for enterprise applications. ]]>
                </description>
                <link>https://www.freecodecamp.org/news/optimize-enterprise-app-performance-with-t-sql-query-tuning-and-indexing-strategies/</link>
                <guid isPermaLink="false">6a5e89d9dfc3c99e37bbdef0</guid>
                
                    <category>
                        <![CDATA[ SQL Query Performance ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Enterprise T-SQL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ SQL Query Tuning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Query Performance ]]>
                    </category>
                
                    <category>
                        <![CDATA[ SQL Performance Tuning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ SQL Server Execution Plan ]]>
                    </category>
                
                    <category>
                        <![CDATA[ optimization ]]>
                    </category>
                
                    <category>
                        <![CDATA[ performance ]]>
                    </category>
                
                    <category>
                        <![CDATA[ SQL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Databases ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Gopinath Karunanithi ]]>
                </dc:creator>
                <pubDate>Mon, 20 Jul 2026 20:49:29 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/1df4fdd1-4c5d-4f0b-a0e6-f40a75de6a8e.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this article, you'll learn how to optimize SQL Server performance using T-SQL query tuning, indexing strategies, execution plans, and real-world optimization techniques for enterprise applications.</p>
<p>Slow SQL queries are one of the biggest bottlenecks in enterprise applications. This guide demonstrates how to analyze execution plans, design effective indexes, rewrite inefficient T-SQL queries, optimize joins and aggregations, and monitor performance using SQL Server tools.</p>
<p>By working through several practical examples, you'll learn how to build faster, scalable, and more maintainable SQL Server workloads.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a href="#heading-introduction">Introduction</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-why-query-performance-matters-in-enterprise-applications">Why Query Performance Matters in Enterprise Applications</a></p>
</li>
<li><p><a href="#heading-how-sql-server-executes-queries">How SQL Server Executes Queries</a></p>
</li>
<li><p><a href="#heading-understanding-execution-plans">Understanding Execution Plans</a></p>
</li>
<li><p><a href="#heading-common-execution-plan-operators">Common Execution Plan Operators</a></p>
</li>
<li><p><a href="#heading-finding-slow-queries">Finding Slow Queries</a></p>
</li>
<li><p><a href="#heading-writing-efficient-where-clauses">Writing Efficient WHERE Clauses</a></p>
</li>
<li><p><a href="#heading-optimizing-join-operations">Optimizing JOIN Operations</a></p>
</li>
<li><p><a href="#heading-optimizing-aggregations">Optimizing Aggregations</a></p>
</li>
<li><p><a href="#heading-common-table-expressions-vs-temporary-tables">Common Table Expressions vs. Temporary Tables</a></p>
</li>
<li><p><a href="#heading-avoiding-common-t-sql-performance-anti-patterns">Avoiding Common T-SQL Performance Anti-Patterns</a></p>
</li>
<li><p><a href="#heading-measuring-before-and-after-optimization">Measuring Before and After Optimization</a></p>
</li>
<li><p><a href="#heading-monitoring-query-performance">Monitoring Query Performance</a></p>
</li>
<li><p><a href="#heading-real-world-example-optimizing-a-reporting-query">Real-World Example: Optimizing a Reporting Query</a></p>
</li>
<li><p><a href="#heading-when-not-to-optimize-prematurely">When NOT to Optimize Prematurely</a></p>
</li>
<li><p><a href="#heading-best-practices-for-enterprise-t-sql-optimization">Best Practices for Enterprise T-SQL Optimization</a></p>
</li>
<li><p><a href="#heading-future-trends-in-sql-performance-optimization">Future Trends in SQL Performance Optimization</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-introduction"><strong>Introduction</strong></h2>
<p>Enterprise application performance often depends more on the database than the application itself. Whether you're building with ASP.NET Core, Java Spring Boot, or Node.js, inefficient database queries can lead to slow API responses, page load delays, timeout errors, and increased infrastructure costs.</p>
<p>While adding CPU, memory, or database replicas may temporarily improve performance, the root cause is often inefficient T-SQL queries, poorly designed indexes, outdated statistics, or suboptimal execution plans. Since the same queries may execute thousands of times per minute, even small optimizations can significantly reduce latency and resource consumption.</p>
<p>In enterprise environments, where databases often contain millions of records and support highly concurrent workloads, query tuning becomes essential for maintaining scalability and responsiveness.</p>
<p>In this article, you'll learn how SQL Server executes queries, how to analyze execution plans, optimize T-SQL, design effective indexing strategies, and apply practical techniques to improve database performance in real-world applications.</p>
<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>To get the most from this tutorial, you should be familiar with:</p>
<ul>
<li><p>Basic SQL and T-SQL syntax</p>
</li>
<li><p>Microsoft SQL Server fundamentals</p>
</li>
<li><p>Primary keys and foreign keys</p>
</li>
<li><p>Basic understanding of indexes</p>
</li>
<li><p>SQL Server Management Studio (SSMS) or Azure Data Studio</p>
</li>
<li><p>Basic knowledge of relational database concepts</p>
</li>
</ul>
<h2 id="heading-why-query-performance-matters-in-enterprise-applications"><strong>Why Query Performance Matters in Enterprise Applications</strong></h2>
<p>Database performance directly affects every layer of an enterprise application. Even if the frontend is highly optimized and the application servers are properly scaled, slow database operations quickly become the limiting factor.</p>
<p>Consider a typical enterprise architecture:</p>
<img src="https://cdn.hashnode.com/uploads/covers/695f02b68a3eda4408ac22af/bfd4d87f-dc93-4411-a066-1375a4344d6d.png" alt="A high-level system architecture diagram illustrating the application flow from the Client to the ASP.NET Core API, then to the Business Services layer, and finally to the SQL Server Database. The components are connected sequentially with arrows, representing the request and data flow through the application layers." style="display:block;margin:0 auto" width="754" height="119" loading="lazy">

<p>Figure 1. High-Level Architecture Showing Client, ASP.NET Core API, Business Services, and SQL Server Database</p>
<p>Figure 1 illustrates the high-level architecture of a typical ASP.NET Core application. Client requests are received by the ASP.NET Core API, which serves as the application's entry point. The API forwards these requests to the Business Services layer, where the core business logic is executed. The Business Services layer then interacts with the SQL Server Database to retrieve or persist data. The sequential flow of requests through these components is represented by the arrows in the diagram.</p>
<p>Although this architecture separates responsibilities and improves maintainability, its overall performance is often constrained by the database. Every request that requires data eventually reaches the SQL Server database. If the database responds slowly, every upstream component (including the Business Services layer, the API, and ultimately the client) must wait for the query to complete.</p>
<p>Consider an order management system in which a dashboard displays customer information, recent orders, invoices, inventory levels, and shipment status. Loading this dashboard may require several independent database queries. While these queries may execute concurrently, the user perceives the combined response time. Consequently, even a small number of poorly optimized queries can significantly increase page load times and degrade the overall user experience.</p>
<p>As database size and application usage grow, performance issues often become increasingly apparent.</p>
<p>Common symptoms include:</p>
<ul>
<li><p>APIs that gradually become slower as data grows</p>
</li>
<li><p>High CPU utilization on the SQL Server</p>
</li>
<li><p>Excessive disk I/O</p>
</li>
<li><p>Blocking between concurrent transactions</p>
</li>
<li><p>Deadlocks during peak usage</p>
</li>
<li><p>Timeout exceptions in application logs</p>
</li>
</ul>
<p>Many of these problems originate from inefficient SQL rather than insufficient hardware.</p>
<p>For example, suppose a customer table contains ten million records. Searching for customers by email without an appropriate index forces SQL Server to examine every row.</p>
<pre><code class="language-sql">SELECT *
FROM Customers
WHERE Email = 'john@example.com';
</code></pre>
<p>Without an index on the Email column, SQL Server performs a table scan, reading every page before locating the desired row.</p>
<p>Adding a properly designed index transforms the same query into an index seek, allowing SQL Server to locate the record almost immediately.</p>
<p>As enterprise datasets continue growing, these differences become increasingly significant.</p>
<h2 id="heading-how-sql-server-executes-queries"><strong>How SQL Server Executes Queries</strong></h2>
<p>Understanding SQL Server's execution process is essential before attempting optimization.</p>
<p>Every query passes through several stages before data is returned.</p>
<h3 id="heading-step-1-parsing">Step 1: Parsing</h3>
<p>SQL Server first validates the syntax.</p>
<pre><code class="language-sql">SELECT Name
FROM Customers;
</code></pre>
<p>If the statement contains syntax errors, execution stops immediately.</p>
<h3 id="heading-step-2-binding">Step 2: Binding</h3>
<p>Next, SQL Server verifies that referenced tables, columns, functions, and objects exist.</p>
<p>For example,</p>
<pre><code class="language-sql">SELECT CustomerName
FROM Customers;
</code></pre>
<p>If CustomerName doesn't exist, SQL Server reports an error before optimization begins.</p>
<h3 id="heading-step-3-query-optimization">Step 3: Query Optimization</h3>
<p>The SQL Server Query Optimizer evaluates multiple possible execution strategies.</p>
<p>It estimates the cost of various approaches, including table scans, index seeks, different join algorithms, parallel execution, and sorting methods.</p>
<p>The optimizer chooses the plan with the lowest estimated cost based on available statistics.</p>
<p>Importantly, developers don't tell SQL Server <em>how</em> to execute a query. They specify <em>what</em> data they need.</p>
<h3 id="heading-step-4-execution-plan-generation">Step 4: Execution Plan Generation</h3>
<p>The optimizer then generates an execution plan.</p>
<p>The execution plan acts as a blueprint describing every operation required to satisfy the query.</p>
<p>For example:</p>
<pre><code class="language-sql">SELECT *
FROM Orders
WHERE CustomerID = 1250;
</code></pre>
<p>Depending on available indexes, SQL Server may choose either clustered Index Seek, Nonclustered Index Seek, Index Scan, or Table Scan. Understanding these operators is the foundation of effective tuning.</p>
<h2 id="heading-understanding-execution-plans"><strong>Understanding Execution Plans</strong></h2>
<p>Execution plans reveal how SQL Server actually processes a query. Rather than guessing why a query performs poorly, execution plans identify the most expensive operations directly.</p>
<p>SQL Server provides two primary plan types:</p>
<ol>
<li><p><strong>Estimated Execution Plan:</strong> Generated without executing the query. It predicts the optimizer's chosen strategy using available statistics.</p>
</li>
<li><p><strong>Actual Execution Plan:</strong> Generated after the query runs, showing the real execution path along with runtime statistics such as row counts and operator costs.</p>
</li>
</ol>
<p>For performance tuning, the actual execution plan is generally more valuable because it exposes differences between estimated and actual behavior.</p>
<p>In SQL Server Management Studio, you can enable the actual execution plan by selecting <strong>Include Actual Execution Plan</strong> before running your query.</p>
<h2 id="heading-common-execution-plan-operators"><strong>Common Execution Plan Operators</strong></h2>
<p>Understanding a handful of common operators makes execution plans much easier to interpret.</p>
<h3 id="heading-table-scan">Table Scan</h3>
<p>A table scan reads every row in a table.</p>
<p><code>Customers ──► Table Scan</code></p>
<p>This is acceptable for small lookup tables but becomes increasingly expensive as tables grow.</p>
<h3 id="heading-index-scan">Index Scan</h3>
<p>An index scan reads every entry within an index.</p>
<p>Although better than scanning the full table, it still processes every index page.</p>
<p><strong>Index Seek</strong></p>
<p>An index seek navigates directly to matching rows.</p>
<p>CustomerID Index<br>&nbsp; &nbsp; &nbsp; &nbsp; │<br>&nbsp; &nbsp; &nbsp; &nbsp; ▼<br>&nbsp; Index Seek</p>
<p>This is generally the most efficient access method for selective queries.</p>
<h3 id="heading-nested-loop-join">Nested Loop Join</h3>
<p>Nested Loop joins perform well when one input contains relatively few rows.</p>
<p>Customers<br>&nbsp; │<br>&nbsp; ▼<br>Nested Loop<br>&nbsp; ▲</p>
<p>│<br>Orders</p>
<p>They're commonly used for OLTP workloads.</p>
<h3 id="heading-hash-match">Hash Match</h3>
<p>Hash joins excel when processing large datasets with no useful indexes. But keep in mind that they consume more memory and may spill to disk if insufficient memory is available.</p>
<h3 id="heading-merge-join">Merge Join</h3>
<p>Merge joins require sorted inputs but can process large result sets efficiently. They're often selected when both datasets are already indexed appropriately.</p>
<h3 id="heading-key-lookup">Key Lookup</h3>
<p>One operator that frequently surprises developers is the <strong>Key Lookup</strong>.</p>
<p>Suppose an index contains only the <code>CustomerID</code> column, but the query also requests <code>Address</code> and <code>PhoneNumber</code>.</p>
<p>SQL Server first performs an Index Seek to locate matching rows, then executes additional lookups against the clustered index to retrieve missing columns.</p>
<p>Although acceptable for a few rows, thousands of key lookups can significantly degrade performance.</p>
<p>In many cases, creating a covering index eliminates these extra lookups entirely. This is a topic we'll explore later in the article.</p>
<h2 id="heading-finding-slow-queries"><strong>Finding Slow Queries</strong></h2>
<p>SQL Server provides several built-in tools that help locate performance bottlenecks in production environments.</p>
<h3 id="heading-using-query-store">Using Query Store</h3>
<p>Query Store records query history, execution plans, runtime statistics, and performance trends over time. Rather than relying on temporary monitoring sessions, it continuously captures valuable performance information, making it one of the most useful features for enterprise SQL Server deployments.</p>
<p>For example, if an application suddenly becomes slower after a deployment, Query Store can compare execution plans before and after the change to determine whether the optimizer selected a less efficient plan.</p>
<p>Typical metrics available include:</p>
<ul>
<li><p>Average execution time</p>
</li>
<li><p>CPU consumption</p>
</li>
<li><p>Logical reads</p>
</li>
<li><p>Execution count</p>
</li>
<li><p>Query plan history</p>
</li>
</ul>
<p>This historical view helps identify regressions that may otherwise be difficult to reproduce.</p>
<h3 id="heading-using-dynamic-management-views-dmvs">Using Dynamic Management Views (DMVs)</h3>
<p>Dynamic Management Views expose internal SQL Server performance information while the server is running.</p>
<p>One commonly used DMV is:</p>
<pre><code class="language-sql">SELECT TOP 10
    qs.execution_count,
    qs.total_worker_time,
    qs.total_elapsed_time,
    SUBSTRING(
        qt.text,
        qs.statement_start_offset / 2,
        (
            CASE
                WHEN qs.statement_end_offset = -1
                THEN LEN(CONVERT(NVARCHAR(MAX), qt.text)) * 2
                ELSE qs.statement_end_offset
            END - qs.statement_start_offset
        ) / 2
    ) AS QueryText
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) qt
ORDER BY qs.total_worker_time DESC;
</code></pre>
<p>This query identifies statements consuming the most CPU time, helping prioritize optimization efforts.</p>
<h3 id="heading-measuring-io-and-execution-time">Measuring I/O and Execution Time</h3>
<p>SQL Server also provides lightweight commands for measuring query performance.</p>
<pre><code class="language-sql">SET STATISTICS IO ON;
SET STATISTICS TIME ON;
</code></pre>
<p>After enabling these options, executing a query displays additional information such as:</p>
<ul>
<li><p>Logical reads</p>
</li>
<li><p>Physical reads</p>
</li>
<li><p>CPU time</p>
</li>
<li><p>Total elapsed time</p>
</li>
</ul>
<p>Consider the following query:</p>
<pre><code class="language-sql">SELECT *
FROM Orders
WHERE CustomerID = 1025;
</code></pre>
<p>The output might resemble:</p>
<p><code>Table 'Orders'.</code></p>
<p><code>Logical reads: 4832</code></p>
<p>SQL Server Execution Times:<br><code>CPU time = 215 ms</code></p>
<p><code>Elapsed time = 287 ms</code></p>
<p>After adding an appropriate index, the same query could produce:</p>
<p><code>Logical reads: 6</code></p>
<p><code>CPU time = 3 ms</code></p>
<p><code>Elapsed time = 5 ms</code></p>
<p>These measurements provide objective evidence that an optimization has improved performance.</p>
<h2 id="heading-writing-efficient-where-clauses"><strong>Writing Efficient WHERE Clauses</strong></h2>
<p>One of the simplest ways to improve query performance is to write <strong>SARGable</strong> predicates. A query is considered SARGable (Search ARGument Able) when SQL Server can efficiently use an index to locate matching rows.</p>
<p>Many developers unintentionally prevent index usage by applying functions directly to indexed columns.</p>
<p>Consider this example:</p>
<pre><code class="language-sql">SELECT *
FROM Orders
WHERE YEAR(OrderDate) = 2025;
</code></pre>
<p>Although the logic is correct, SQL Server must evaluate the YEAR() function for every row before performing the comparison. As a result, it can't efficiently seek into an index on <code>OrderDate</code>.</p>
<p>A better approach is to compare the column directly.</p>
<pre><code class="language-sql">SELECT *
FROM Orders
WHERE OrderDate &gt;= '2025-01-01'
AND OrderDate &lt; '2026-01-01';
</code></pre>
<p>This version allows SQL Server to perform an index seek rather than scanning the entire table.</p>
<p>Similarly, avoid implicit data type conversions.</p>
<p>Instead of:</p>
<p><code>WHERE CustomerID = '100'</code></p>
<p>prefer:</p>
<p><code>WHERE CustomerID = 100</code></p>
<p>Matching the column's data type eliminates unnecessary conversions during query execution.</p>
<h2 id="heading-optimizing-join-operations"><strong>Optimizing JOIN Operations</strong></h2>
<p>Enterprise applications rarely query a single table. Most business operations involve combining data from multiple related tables, making joins one of the most important optimization areas.</p>
<p>Consider an order management system:</p>
<pre><code class="language-sql">SELECT
    c.Name,
    o.OrderDate,
    o.TotalAmount
FROM Customers c
INNER JOIN Orders o
    ON c.CustomerID = o.CustomerID;
</code></pre>
<p>When both <code>CustomerID</code> columns are indexed, SQL Server can efficiently join the tables.</p>
<p>But poor indexing often forces SQL Server to scan one or both tables, dramatically increasing execution time.</p>
<h4 id="heading-exists-vs-in"><code>EXISTS</code> vs. <code>IN</code></h4>
<p>Another common optimization involves replacing <code>IN</code> with <code>EXISTS</code> for large subqueries.</p>
<p>Less efficient:</p>
<pre><code class="language-sql">SELECT *
FROM Customers
WHERE CustomerID IN (
    SELECT CustomerID
    FROM Orders
);
</code></pre>
<p>Better:</p>
<pre><code class="language-sql">SELECT *
FROM Customers c
WHERE EXISTS (
    SELECT 1
    FROM Orders o
    WHERE o.CustomerID = c.CustomerID
);
</code></pre>
<p>For correlated lookups involving large datasets, <code>EXISTS</code> often enables more efficient execution plans.</p>
<h4 id="heading-eliminate-unnecessary-joins">Eliminate Unnecessary Joins</h4>
<p>Sometimes queries include tables whose data is never used.</p>
<p>For example:</p>
<pre><code class="language-sql">SELECT
    o.OrderID,
    c.Name
FROM Orders o
INNER JOIN Customers c
    ON o.CustomerID = c.CustomerID
INNER JOIN Regions r
    ON c.RegionID = r.RegionID;
</code></pre>
<p>If no columns from Regions are selected or filtered, removing the join reduces unnecessary work and simplifies the execution plan.</p>
<h2 id="heading-optimizing-aggregations"><strong>Optimizing Aggregations</strong></h2>
<p>Aggregations become increasingly expensive as datasets grow. Reporting systems frequently summarize millions of rows using functions such as <code>SUM()</code>, <code>COUNT()</code>, <code>AVG()</code>, and <code>MAX()</code>.</p>
<p>A straightforward aggregation might look like this:</p>
<pre><code class="language-sql">SELECT
    CustomerID,
    SUM(TotalAmount)
FROM Orders
GROUP BY CustomerID;
</code></pre>
<p>Although simple, performance depends heavily on indexing and data distribution.</p>
<p>If the query repeatedly scans millions of rows, consider creating an index on <code>CustomerID</code>.</p>
<p>Window functions often provide a cleaner alternative to complex subqueries.</p>
<p>For example, identifying each customer's most recent order:</p>
<pre><code class="language-sql">SELECT
    CustomerID,
    OrderDate,
    ROW_NUMBER() OVER (
        PARTITION BY CustomerID
        ORDER BY OrderDate DESC
    ) AS RowNum
FROM Orders;
</code></pre>
<p>Window functions allow SQL Server to calculate rankings and running totals without complicated self-joins.</p>
<p>Whenever possible, avoid unnecessary sorting operations, since sorting large result sets consumes considerable CPU and memory.</p>
<h2 id="heading-common-table-expressions-vs-temporary-tables"><strong>Common Table Expressions vs. Temporary Tables</strong></h2>
<p>Both Common Table Expressions (CTEs) and temporary tables help simplify complex queries, but they serve different purposes.</p>
<p>A CTE provides a readable way to structure intermediate query logic.</p>
<pre><code class="language-sql">WITH RecentOrders AS
(
    SELECT *
    FROM Orders
    WHERE OrderDate &gt;= DATEADD(DAY, -30, GETDATE())
)
SELECT *
FROM RecentOrders;
</code></pre>
<p>CTEs improve readability and maintainability but aren't materialized automatically. SQL Server may execute the underlying logic multiple times depending on the execution plan.</p>
<p>Temporary tables, on the other hand, physically store intermediate results.</p>
<pre><code class="language-sql">SELECT *
INTO #RecentOrders
FROM Orders
WHERE OrderDate &gt;= DATEADD(DAY, -30, GETDATE());

SELECT *
FROM #RecentOrders;
</code></pre>
<p>Temporary tables become particularly useful when:</p>
<ul>
<li><p>Intermediate results are reused multiple times</p>
</li>
<li><p>Large datasets need additional indexing</p>
</li>
<li><p>Complex joins benefit from breaking queries into stages</p>
</li>
</ul>
<p>Choosing between the two depends on workload characteristics rather than personal preference.</p>
<h2 id="heading-avoiding-common-t-sql-performance-anti-patterns">Avoiding Common T-SQL Performance Anti-Patterns</h2>
<p>Many performance issues stem from common coding habits rather than complex database problems.</p>
<h3 id="heading-avoid-select">Avoid <code>SELECT *</code></h3>
<p>Fetching every column increases network traffic, memory consumption, and I/O.</p>
<p>Instead of this:</p>
<pre><code class="language-sql">SELECT *
FROM Customers;
</code></pre>
<p>Retrieve only the required columns:</p>
<pre><code class="language-sql">SELECT
    CustomerID,
    Name,
    Email
FROM Customers;
</code></pre>
<p>This reduces both data transfer and execution costs.</p>
<h3 id="heading-avoid-scalar-functions-in-where-clauses">Avoid Scalar Functions in <code>WHERE</code> Clauses</h3>
<p>Scalar functions execute once per row, preventing efficient index usage.</p>
<p>Instead of:</p>
<pre><code class="language-sql">WHERE UPPER(Name) = 'JOHN'
</code></pre>
<p>store normalized values or use case-insensitive collations where appropriate.</p>
<h3 id="heading-avoid-cursors-for-row-by-row-processing">Avoid Cursors for Row-by-Row Processing</h3>
<p>Cursors process records sequentially.</p>
<pre><code class="language-sql">DECLARE CustomerCursor CURSOR
FOR
SELECT CustomerID
FROM Customers;
</code></pre>
<p>Although sometimes necessary, cursor-based solutions rarely scale well for enterprise workloads.</p>
<p>Most cursor logic can be rewritten using set-based operations.</p>
<p>For example:</p>
<p>Instead of updating rows individually:</p>
<pre><code class="language-sql">UPDATE Customers
SET Status = 'Active'
WHERE LastLogin &gt;= DATEADD(DAY, -30, GETDATE());
</code></pre>
<p>SQL Server processes the entire set efficiently rather than iterating row by row.</p>
<h3 id="heading-reduce-correlated-subqueries">Reduce Correlated Subqueries</h3>
<p>Correlated subqueries execute repeatedly for each outer row.</p>
<p>For example:</p>
<pre><code class="language-sql">SELECT
    CustomerID,
    (
        SELECT COUNT(*)
        FROM Orders o
        WHERE o.CustomerID = c.CustomerID
    ) AS OrderCount
FROM Customers c;
</code></pre>
<p>Rewriting this using joins and aggregation often produces more efficient execution plans.</p>
<pre><code class="language-sql">SELECT
    c.CustomerID,
    COUNT(o.OrderID) AS OrderCount
FROM Customers c
LEFT JOIN Orders o
    ON c.CustomerID = o.CustomerID
GROUP BY c.CustomerID;
</code></pre>
<p>The rewritten version allows SQL Server to process the data in a single pass rather than executing thousands of nested queries.</p>
<h2 id="heading-measuring-before-and-after-optimization"><strong>Measuring Before and After Optimization</strong></h2>
<p>Effective tuning always follows the same cycle:</p>
<ol>
<li><p>Measure the original query using Query Store or SET STATISTICS.</p>
</li>
<li><p>Analyze the execution plan.</p>
</li>
<li><p>Identify expensive operators such as scans, sorts, or key lookups.</p>
</li>
<li><p>Apply one targeted optimization, such as rewriting the query or adding an index.</p>
</li>
<li><p>Measure again using the same workload.</p>
</li>
</ol>
<p>This iterative approach ensures that every optimization is evidence-based rather than relying on assumptions. In enterprise environments, even small improvements to frequently executed queries can significantly reduce CPU usage, disk I/O, and response times.</p>
<h2 id="heading-monitoring-query-performance"><strong>Monitoring Query Performance</strong></h2>
<p>Query tuning is an ongoing process rather than a one-time optimization effort. As enterprise databases grow, data distributions change, index fragment, and application workloads evolve. Queries that once performed well may gradually become inefficient.</p>
<p>SQL Server provides several built-in tools for identifying performance issues.</p>
<h3 id="heading-query-store">Query Store</h3>
<p>Query Store records query history, execution statistics, execution plans, and runtime information.</p>
<p>It helps answer questions such as:</p>
<ul>
<li><p>Which queries consume the most CPU?</p>
</li>
<li><p>Which execution plans changed recently?</p>
</li>
<li><p>Which query became slower after deployment?</p>
</li>
<li><p>Which indexes are no longer being used?</p>
</li>
</ul>
<p>Enable Query Store:</p>
<pre><code class="language-sql">ALTER DATABASE SalesDB
SET QUERY_STORE = ON;
</code></pre>
<p>View top resource-consuming queries:</p>
<pre><code class="language-sql">SELECT
    qt.query_sql_text,
    rs.avg_duration,
    rs.avg_cpu_time
FROM sys.query_store_query_text qt
JOIN sys.query_store_query q
    ON qt.query_text_id = q.query_text_id
JOIN sys.query_store_plan p
    ON q.query_id = p.query_id
JOIN sys.query_store_runtime_stats rs
    ON p.plan_id = rs.plan_id
ORDER BY rs.avg_duration DESC;
</code></pre>
<p>Instead of relying on user complaints, administrators can proactively detect regressions before they affect production workloads.</p>
<h3 id="heading-dynamic-management-views-dmvs">Dynamic Management Views (DMVs)</h3>
<p>SQL Server exposes runtime statistics through Dynamic Management Views.</p>
<p>Example:</p>
<pre><code class="language-sql">SELECT TOP 10
    qs.execution_count,
    qs.total_elapsed_time / qs.execution_count AS AvgTime,
    st.text
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st
ORDER BY AvgTime DESC;
</code></pre>
<p>This query highlights expensive SQL statements currently cached by SQL Server.</p>
<h3 id="heading-actual-execution-plans">Actual Execution Plans</h3>
<p>Execution plans remain one of the most valuable tuning tools.</p>
<p>When reviewing plans, look for:</p>
<ul>
<li><p>Table scans</p>
</li>
<li><p>Index scans on large tables</p>
</li>
<li><p>Key lookups</p>
</li>
<li><p>Sort operators</p>
</li>
<li><p>Hash Match operations</p>
</li>
<li><p>Missing index recommendations</p>
</li>
<li><p>Large memory grants</p>
</li>
</ul>
<p>The graphical execution plan often pinpoints the exact operator responsible for poor performance.</p>
<h2 id="heading-real-world-example-optimizing-a-reporting-query"><strong>Real-World Example: Optimizing a Reporting Query</strong></h2>
<p>Consider an enterprise reporting system that generates monthly sales summaries.</p>
<p>Original query:</p>
<pre><code class="language-sql">SELECT
    CustomerName,
    SUM(TotalAmount)
FROM Orders
WHERE YEAR(OrderDate) = 2025
GROUP BY CustomerName;
</code></pre>
<p>Although simple, this query performs poorly because <code>YEAR()</code> prevents index seeks and the entire Orders table must be scanned.</p>
<p>Rewrite it like this:</p>
<pre><code class="language-sql">SELECT
    CustomerName,
    SUM(TotalAmount)
FROM Orders
WHERE OrderDate &gt;= '2025-01-01'
AND OrderDate &lt; '2026-01-01'
GROUP BY CustomerName;
</code></pre>
<p>Then create an appropriate index:</p>
<pre><code class="language-sql">CREATE INDEX IX_Orders_OrderDate
ON Orders(OrderDate)
INCLUDE (CustomerName, TotalAmount);
</code></pre>
<p>Performance improvements may include:</p>
<ul>
<li><p>Index Seek instead of Table Scan</p>
</li>
<li><p>Lower logical reads</p>
</li>
<li><p>Reduced CPU utilization</p>
</li>
<li><p>Faster execution time</p>
</li>
<li><p>Better scalability under concurrent reporting workloads</p>
</li>
</ul>
<p>This illustrates that query rewriting and indexing typically produce much larger gains than simply adding hardware.</p>
<h2 id="heading-when-not-to-optimize-prematurely"><strong>When NOT to Optimize Prematurely</strong></h2>
<p>Performance optimization should be driven by evidence, not assumptions. Premature or unnecessary tuning can increase complexity, make queries harder to maintain, and sometimes even reduce overall system performance.</p>
<p>Before making changes, use tools such as Query Store, execution plans, and SQL Server DMVs to identify the actual bottlenecks.</p>
<h3 id="heading-avoid-optimizing-without-profiling">Avoid Optimizing Without Profiling</h3>
<p>Don't rewrite queries simply because they look inefficient. Measure execution time, logical reads, CPU usage, and execution plans first so that optimization efforts target real performance problems rather than perceived ones.</p>
<h3 id="heading-dont-create-indexes-for-every-query">Don't Create Indexes for Every Query</h3>
<p>While indexes can dramatically improve read performance, every additional index increases storage requirements and slows <code>INSERT</code>, <code>UPDATE</code>, and <code>DELETE</code> operations. Create indexes only for frequently executed queries that demonstrate a measurable benefit.</p>
<h3 id="heading-dont-force-query-hints-unnecessarily">Don't Force Query Hints Unnecessarily</h3>
<p>Query hints such as <code>OPTION (FORCE ORDER)</code> or <code>OPTION (RECOMPILE)</code> can override SQL Server's optimizer. They should be used only after careful testing, as they may solve one problem while causing performance regressions elsewhere.</p>
<pre><code class="language-sql">SELECT *
FROM Orders
WHERE CustomerID = @CustomerID
OPTION (RECOMPILE);
</code></pre>
<h3 id="heading-dont-over-normalize-or-de-normalize-without-evidence">Don't Over-Normalize or De-Normalize Without Evidence</h3>
<p>Highly normalized schemas may require expensive joins, while excessive denormalization can introduce redundant data and update anomalies. Choose the appropriate design based on actual workload characteristics rather than assumptions.</p>
<h3 id="heading-balance-read-and-write-performance">Balance Read and Write Performance</h3>
<p>An optimization that accelerates reporting queries may slow transactional workloads due to additional index maintenance. Always evaluate how tuning changes affect both read-heavy and write-heavy operations before deploying them to production.</p>
<h2 id="heading-best-practices-for-enterprise-t-sql-optimization"><strong>Best Practices for Enterprise T-SQL Optimization</strong></h2>
<p>Successful tuning is about applying consistent engineering practices rather than isolated optimizations. We've already discussed some of these best practices, but I'll list them all here for review and completeness (and as a quick reference):</p>
<h3 id="heading-design-indexes-around-queries">Design Indexes Around Queries</h3>
<p>Indexes should reflect actual application workloads.</p>
<p>Instead of indexing every column, identify common <code>WHERE</code> clauses, frequently joined columns, <code>ORDER BY</code> columns, and <code>GROUP BY</code> columns.</p>
<p>Build indexes that support these operations efficiently.</p>
<h3 id="heading-avoid-over-indexing">Avoid Over-Indexing</h3>
<p>More indexes are not always better.</p>
<p>Every <code>INSERT</code>, <code>UPDATE</code>, and <code>DELETE</code> operation must maintain every index.</p>
<p>Too many indexes increase storage, write latency, maintenance time, and fragmentation. Keep only indexes that provide measurable value.</p>
<h3 id="heading-keep-statistics-updated">Keep Statistics Updated</h3>
<p>Outdated statistics lead to poor execution plans.</p>
<p>Update statistics regularly:</p>
<p><code>UPDATE STATISTICS Orders;</code></p>
<p>Or update the entire database:</p>
<p><code>EXEC sp_updatestats;</code></p>
<p>Many performance issues disappear after SQL Server receives accurate distribution statistics.</p>
<h3 id="heading-monitor-index-fragmentation">Monitor Index Fragmentation</h3>
<p>Indexes become fragmented as data changes.</p>
<p>Check fragmentation:</p>
<pre><code class="language-sql">SELECT
    avg_fragmentation_in_percent,
    page_count
FROM sys.dm_db_index_physical_stats
(
    DB_ID(),
    OBJECT_ID('Orders'),
    NULL,
    NULL,
    'LIMITED'
);
</code></pre>
<h3 id="heading-avoid-select">Avoid SELECT *</h3>
<p>Retrieve only required columns.</p>
<p>Instead of:</p>
<pre><code class="language-sql">SELECT *
FROM Customers;
</code></pre>
<p>Use this:</p>
<pre><code class="language-sql">SELECT CustomerID,
       CustomerName,
       Email
FROM Customers;
</code></pre>
<p>Benefits include smaller network payloads, better covering index usage, lower memory consumption, and reduced I/O.</p>
<h3 id="heading-test-with-production-like-data">Test with Production-Like Data</h3>
<p>Queries that perform well on development databases containing thousands of rows may behave very differently against production systems with hundreds of millions of records.</p>
<p>Always validate execution plans, memory grants, CPU usage, parallelism, and logical reads using realistic datasets.</p>
<h2 id="heading-future-trends-in-sql-performance-optimization"><strong>Future Trends in SQL Performance Optimization</strong></h2>
<h3 id="heading-1-intelligent-query-processing">1. Intelligent Query Processing</h3>
<p>Modern versions of SQL Server include features such as adaptive query processing, memory grant feedback, and automatic plan correction. These capabilities allow the query optimizer to adjust execution strategies based on actual workload patterns, improving performance without manual tuning.</p>
<h3 id="heading-2-cloud-native-database-optimization">2. Cloud-Native Database Optimization</h3>
<p>Cloud database platforms provide built-in capabilities such as automatic indexing recommendations, continuous performance monitoring, and self-tuning features. These services reduce administrative overhead while helping maintain consistent query performance as workloads grow.</p>
<h3 id="heading-3-ai-assisted-performance-tuning">3. AI-Assisted Performance Tuning</h3>
<p>Artificial intelligence is becoming a valuable assistant for database optimization. AI-powered tools can analyze execution plans, recommend indexes, identify inefficient queries, and even suggest T-SQL rewrites, enabling developers to resolve performance issues earlier in the development lifecycle.</p>
<h3 id="heading-4-performance-engineering-by-default">4. Performance Engineering by Default</h3>
<p>Database optimization is shifting from reactive troubleshooting to proactive performance engineering. By incorporating query analysis, indexing reviews, and performance testing into CI/CD pipelines, teams can detect regressions before they reach production.</p>
<h3 id="heading-strong-fundamentals-still-matter">Strong Fundamentals Still Matter</h3>
<p>Despite advances in automation, understanding execution plans, indexing strategies, query design, and statistics remains essential. Automated tools provide recommendations, but experienced developers and DBAs are still needed to validate trade-offs and ensure optimizations align with business requirements.</p>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>Effective T-SQL performance optimization isn't about applying isolated tricks or adding indexes indiscriminately. It requires understanding how SQL Server executes queries, accesses data, and chooses execution plans.</p>
<p>By combining efficient query design with well-planned indexing strategies, accurate statistics, and continuous monitoring, you can dramatically reduce latency, lower resource consumption, and improve scalability across enterprise applications.</p>
<p>Rather than waiting for performance problems to appear in production, teams should make query tuning a routine part of the development lifecycle. Regularly reviewing execution plans, monitoring workload patterns through Query Store, validating indexes against real application behavior, and testing with production-scale datasets creates a foundation for predictable and reliable database performance.</p>
<p>As enterprise systems continue to grow in complexity and data volume, organizations that treat performance optimization as an ongoing engineering discipline will be better equipped to deliver responsive, scalable, and cost-effective applications.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Database Version Control with Liquibase and Spring Boot ]]>
                </title>
                <description>
                    <![CDATA[ Picture this familiar scenario: you're working on a new feature that requires a new database column. You open your local database client, write an ALTER TABLE statement, and execute it. Your code work ]]>
                </description>
                <link>https://www.freecodecamp.org/news/database-version-control-with-liquibase-and-spring-boot/</link>
                <guid isPermaLink="false">6a277a781aef44b8099e978b</guid>
                
                    <category>
                        <![CDATA[ Databases ]]>
                    </category>
                
                    <category>
                        <![CDATA[ version control ]]>
                    </category>
                
                    <category>
                        <![CDATA[ spring-boot ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Java ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ashutosh Krishna ]]>
                </dc:creator>
                <pubDate>Tue, 09 Jun 2026 02:29:12 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/dbadec79-8248-4ef4-aa9c-126250db7a64.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Picture this familiar scenario: you're working on a new feature that requires a new database column. You open your local database client, write an <code>ALTER TABLE</code> statement, and execute it. Your code works perfectly. You commit the Java code, push it to the repository, and go grab a coffee.</p>
<p>A few hours later, a teammate pulls your branch, runs the application, and everything crashes.</p>
<p>"Hey," they ask across the room (or in a Slack channel), "did you change the database?"</p>
<p>You quickly realize you forgot to share the SQL script. You paste it into the chat. They run it. Everything works. Then, a week later, the deployment to the staging environment fails for the exact same reason. By the time this code reaches production, everyone is asking a variation of the same terrified question: "Which SQL script should I run?"</p>
<p>This situation is called schema drift. It happens when the state of your database diverges across different environments. Staging has one schema, production has another, and every developer's local machine is a unique snowflake of untested database modifications.</p>
<p>Managing database changes manually is a recipe for deployment headaches and team collaboration challenges. Application code is stateless and easy to replace. Databases are stateful. Databases have surprisingly good memories, and they rarely forget a bad migration.</p>
<p>Liquibase solves this problem by bringing version-control discipline to your database changes. Instead of passing around SQL files and hoping people remember to run them, you define your database changes in code. These changes travel with your application repository and execute automatically.</p>
<p>Here is a high-level look at how this architecture works:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/41202410-4d42-4343-98df-912420dbfb15.png" alt="Architecture diagram showing code flowing from a developer to a database via Git, Spring Boot, and Liquibase." style="display:block;margin:0 auto" width="482" height="771" loading="lazy">

<p>Think about the journey of a single database change. A developer commits their database migration alongside their Java code into Git. When the CI/CD pipeline (or a teammate) pulls that code, the Spring Boot application starts. But before the app fully boots up and accepts web traffic, Liquibase intercepts the process. It acts as a gatekeeper, connecting to the database and applying the required schema changes. This ensures the database exactly matches the code's expectations before a single user makes a request.</p>
<h2 id="heading-why-database-version-control-matters">Why Database Version Control Matters</h2>
<p>If you've spent any time working on team-based applications, you've probably seen a folder structure that looks exactly like this:</p>
<pre><code class="language-plaintext">project-sql-scripts/
├── create_employee_table.sql
├── create_employee_table_final.sql
├── create_employee_table_final_v2.sql
├── add_email_column.sql
├── latest.sql
└── definitely_latest_use_this_one.sql
</code></pre>
<p>The phrase "just run this SQL script manually" has launched many memorable incidents.</p>
<p>When you rely on manual database updates, you guarantee failure at scale. Onboarding a new developer becomes an archeological expedition to figure out how to build the local schema. Deployments become stressful events requiring a checklist of manual queries that must be run in a highly specific order.</p>
<p>Version-controlled database changes treat your schema as code. When your database changes live alongside your application logic, you gain several immediate benefits:</p>
<ul>
<li><p><strong>Consistency:</strong> Every environment (local, staging, production) applies the exact same changes in the exact same order.</p>
</li>
<li><p><strong>Safety:</strong> You eliminate the human error of skipping a script or running an outdated query.</p>
</li>
<li><p><strong>Visibility:</strong> You can look at a Git commit and see exactly how the Java code and the database schema changed together to support a new feature.</p>
</li>
</ul>
<p>Git solved version control for code. Liquibase helps prevent databases from becoming the rebellious sibling.</p>
<h2 id="heading-what-is-liquibase">What is Liquibase?</h2>
<p>At its core, Liquibase is a database migration tool that tracks and applies schema changes in a predictable and repeatable way.</p>
<p>Instead of writing loose SQL scripts, you write "migrations" (also called changeSets). Liquibase reads these files, compares them against a tracking table inside your actual database, and figures out exactly what needs to be executed to bring the database up to date.</p>
<p>To use Liquibase effectively, you only need to understand a few conceptual terms:</p>
<ul>
<li><p><strong>changeLog:</strong> The master file. This is essentially a list that tells Liquibase which migration files to execute and in what order.</p>
</li>
<li><p><strong>changeSet:</strong> A single, atomic change to your database. Creating a table is one changeSet. Adding a column is another.</p>
</li>
<li><p><strong>Migration History:</strong> A table Liquibase automatically creates in your database (called <code>DATABASECHANGELOG</code>) to remember which changeSets have already been executed.</p>
</li>
<li><p><strong>Checksums:</strong> A unique hash generated for every changeSet. Liquibase uses this to detect if someone secretly modified a file after it was already executed.</p>
</li>
</ul>
<p>When you integrate Liquibase with Spring Boot, the migration process happens completely automatically during the application startup phase.</p>
<img src="https://cdn.hashnode.com/uploads/covers/61c1acb4a90dea775da8262b/410e7bdb-da2f-4c48-a5b2-20a45fb20956.png" alt="Sequence diagram of Spring Boot startup where Liquibase checks the tracking table, locks the database, runs migrations, and releases the lock before allowing HTTP traffic." style="display:block;margin:0 auto" width="645" height="528" loading="lazy">

<p>During startup, Liquibase takes control before your web server is allowed to receive HTTP traffic. It reaches into the database and checks the tracking table to see which migrations have already run. If it finds new migrations in your local files, it locks the database to prevent concurrent updates, executes the changes, records the new history, and finally releases the lock. Only after this entire process completes does Spring Boot finish booting up.</p>
<p>Because Liquibase runs before Spring Boot fully initializes the web server, your application will never serve traffic with an outdated database schema. If a migration fails, the application fails to start, protecting your system from entering a broken state.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<p>Now that you understand the theory, let's build something real. We're going to build the database layer for an Employee Management API.</p>
<p>For this project we'll use:</p>
<ul>
<li><p>Java 17+</p>
</li>
<li><p>Spring Boot 3.x</p>
</li>
<li><p>Maven</p>
</li>
<li><p>Liquibase</p>
</li>
<li><p>H2 Database</p>
</li>
</ul>
<p>We're using H2 because it's an in-memory database that requires zero installation. You can run this project immediately without configuring Docker containers or installing database servers. But everything you learn here applies exactly the same way to PostgreSQL, MySQL, SQL Server, or Oracle.</p>
<p>If you're generating this project via <a href="https://start.spring.io/">Spring Initializr</a>, select the following dependencies: Spring Web, Spring Data JPA, Liquibase Migration, and H2 Database.</p>
<p>In your <code>pom.xml</code>, you'll see the critical dependencies that make this work:</p>
<pre><code class="language-xml">&lt;dependencies&gt;
    &lt;dependency&gt;
        &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
        &lt;artifactId&gt;spring-boot-starter-web&lt;/artifactId&gt;
    &lt;/dependency&gt;
    &lt;dependency&gt;
        &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
        &lt;artifactId&gt;spring-boot-starter-data-jpa&lt;/artifactId&gt;
    &lt;/dependency&gt;

    &lt;dependency&gt;
        &lt;groupId&gt;com.h2database&lt;/groupId&gt;
        &lt;artifactId&gt;h2&lt;/artifactId&gt;
        &lt;scope&gt;runtime&lt;/scope&gt;
    &lt;/dependency&gt;

    &lt;dependency&gt;
        &lt;groupId&gt;org.liquibase&lt;/groupId&gt;
        &lt;artifactId&gt;liquibase-core&lt;/artifactId&gt;
    &lt;/dependency&gt;
&lt;/dependencies&gt;
</code></pre>
<p>Next, configure Spring Boot to talk to H2 and find your Liquibase files. Open your <code>src/main/resources/application.properties</code> file and add the following:</p>
<pre><code class="language-plaintext"># H2 Database Configuration
spring.datasource.url=jdbc:h2:file:./data/employeedb;DB_CLOSE_DELAY=-1
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# Enable H2 Console to inspect the database in your browser
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

# Liquibase Configuration
spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml
</code></pre>
<p>That last line is the most important. It tells Spring Boot exactly where to find the "master list" of your database changes.</p>
<p>Note: We're using a file-based H2 database instead of an in-memory database. The problem with an in-memory database is that it completely wipes itself clean every time you restart Spring Boot.</p>
<p>While Liquibase will happily rebuild the schema from scratch on every boot, a <strong>file-based</strong> database is much better for this tutorial (and for real-world local development). With a file-based database, your data, and more importantly, your Liquibase history, will actually persist between application restarts.</p>
<h2 id="heading-understanding-core-liquibase-concepts">Understanding Core Liquibase Concepts</h2>
<p>Before we write our first table, we need to understand how Liquibase organizes files. Liquibase uses a hierarchical structure.</p>
<p>Think of it like a book. The <code>changeLog</code> is the table of contents, and the <code>changeSets</code> are the actual chapters.</p>
<ol>
<li><p><strong>The Master ChangeLog:</strong> This is the entry point. It rarely contains actual database changes. Instead, its only job is to include other files in a specific order.</p>
</li>
<li><p><strong>Child ChangeLogs:</strong> These group related changes together.</p>
</li>
<li><p><strong>ChangeSets:</strong> These are the actual, atomic database commands (like creating a table or adding a column).</p>
</li>
</ol>
<p>Here's a visual breakdown of how this hierarchy works in a real Spring Boot project:</p>
<img src="https://cdn.hashnode.com/uploads/covers/61c1acb4a90dea775da8262b/2cbd3054-d0e4-4129-b0a4-015b08c7258c.png" alt="File structure diagram showing a master changelog XML file pointing to three child migration files in strict chronological order." style="display:block;margin:0 auto" width="993" height="259" loading="lazy">

<p>Liquibase organizes migrations hierarchically. You maintain a single master file that acts as a table of contents. This master file rarely holds actual SQL commands. Instead, it explicitly includes child XML files in a strict execution order. Each of those child files (like <code>01-create-employees.xml</code>) contains one or more individual database commands, which Liquibase calls changeSets.</p>
<p>A <code>changeSet</code> is uniquely identified by three things:</p>
<ul>
<li><p><strong>id:</strong> A unique string (often a number or a Jira ticket ID).</p>
</li>
<li><p><strong>author:</strong> The person who wrote the migration.</p>
</li>
<li><p><strong>file path:</strong> Where the file is located.</p>
</li>
</ul>
<p>When Liquibase runs, it looks at a <code>changeSet</code>, calculates a cryptographic hash of its contents (a checksum), and records the id, author, and checksum in the database. If it sees that exact combination of id, author, and file path in the database again on the next startup, it skips it.</p>
<h2 id="heading-create-the-initial-employee-schema-version-1">Create the Initial Employee Schema (Version 1)</h2>
<p>Let's write our first version. We need a table to store employees.</p>
<p>First, create the master file at <code>src/main/resources/db/changelog/db.changelog-master.xml</code>:</p>
<pre><code class="language-xml">&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd"&gt;

    &lt;include file="db/changelog/changes/01-create-employees.xml"/&gt;

&lt;/databaseChangeLog&gt;
</code></pre>
<p>Next, create the actual migration file at <code>src/main/resources/db/changelog/changes/01-create-employees.xml</code>:</p>
<pre><code class="language-xml">&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd"&gt;

    &lt;changeSet id="1" author="ashutoshkrris"&gt;
        &lt;createTable tableName="employees"&gt;
            &lt;column name="id" type="BIGINT" autoIncrement="true"&gt;
                &lt;constraints primaryKey="true" nullable="false"/&gt;
            &lt;/column&gt;
            &lt;column name="first_name" type="VARCHAR(50)"&gt;
                &lt;constraints nullable="false"/&gt;
            &lt;/column&gt;
            &lt;column name="last_name" type="VARCHAR(50)"&gt;
                &lt;constraints nullable="false"/&gt;
            &lt;/column&gt;
        &lt;/createTable&gt;
    &lt;/changeSet&gt;

&lt;/databaseChangeLog&gt;
</code></pre>
<p>Let's look at what we just did. We defined a <code>changeSet</code> with an <code>id</code> of "1" and an <code>author</code> of "ashutoshkrris". Inside, we used Liquibase's XML syntax to define a table.</p>
<p>Why use XML instead of plain SQL? Because Liquibase is database-agnostic. This exact XML will generate the correct auto-increment syntax for PostgreSQL (<code>SERIAL</code>), MySQL (<code>AUTO_INCREMENT</code>), or Oracle (<code>IDENTITY</code>). You define the structure, and Liquibase translates it to the specific database dialect.</p>
<p>Now, run your Spring Boot application. Watch your terminal output. You'll see logs similar to this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/61c1acb4a90dea775da8262b/69171f93-d54b-487b-a09a-9fd49cad6b6a.png" alt="Terminal Logs for Liquibase Startup" style="display:block;margin:0 auto" width="1810" height="518" loading="lazy">

<p>Liquibase realized the database was empty. It automatically created its tracking table (<code>DATABASECHANGELOG</code>), read our <code>changeSet</code>, executed the table creation, and recorded the event.</p>
<p>If you restart the application right now, Liquibase will run again. But this time, it'll check the <code>DATABASECHANGELOG</code> table, see that <code>id="1"</code> and <code>author="ashutoshkrris"</code> has already been executed, and silently skip it. Your database is now safely version-controlled.</p>
<h2 id="heading-what-just-happened">What Just Happened?</h2>
<p>Up to this point, Liquibase might feel a bit like magic. You dropped an XML file into a folder, started Spring Boot, and your database schema transformed.</p>
<p>But understanding how Liquibase actually works under the hood is critical. If you understand the startup sequence, you'll know exactly how to debug deployments when things eventually go wrong.</p>
<p>When your Spring Boot application starts, it doesn't immediately begin accepting web requests. First, it initializes its internal components. When it creates the Liquibase component, the migration process begins.</p>
<p>Here's exactly what happens during that startup phase:</p>
<img src="https://cdn.hashnode.com/uploads/covers/61c1acb4a90dea775da8262b/4625b403-3f12-4015-9f46-2d0966c35e30.png" alt="Detailed sequence diagram showing Liquibase checking the lock table, acquiring the lock, running unexecuted migrations, and releasing the lock before Tomcat starts." style="display:block;margin:0 auto" width="832" height="766" loading="lazy">

<p>Let's trace the exact sequence. When Spring Boot initializes Liquibase, the very first thing the tool does is query the lock table to ensure no other application instance is currently migrating the database. If the coast is clear, it claims the lock. It then calculates cryptographic checksums for your local XML files, compares them against the database history, executes any missing changes, and logs them. Finally, it releases the lock so the Tomcat web server can safely start.</p>
<p>This sequence guarantees that your application will never serve a user request before the database schema is completely ready to handle it.</p>
<h2 id="heading-inspecting-the-database-liquibase-metadata-tables">Inspecting the Database: Liquibase Metadata Tables</h2>
<p>Let's look at what this history and locking actually looks like inside the database itself. Since we configured the H2 Console earlier, we can inspect the raw tables.</p>
<p>While your Spring Boot application is running, open your browser and navigate to <code>http://localhost:8080/h2-console</code>. Connect using the JDBC URL <code>jdbc:h2:file:./data/employeedb</code> with the username <code>sa</code> and a blank password.</p>
<p>Inside, you'll see your <code>employees</code> table. You'll also see two extra tables created automatically by Liquibase: <code>DATABASECHANGELOG</code> and <code>DATABASECHANGELOGLOCK</code>.</p>
<h3 id="heading-the-databasechangelog-table">The <code>DATABASECHANGELOG</code> Table</h3>
<p>This table is the brain of your migration strategy. It acts as the permanent ledger of every database change ever applied to this environment.</p>
<p>If you run <code>SELECT * FROM DATABASECHANGELOG;</code>, you'll see output that looks like this:</p>
<table>
<thead>
<tr>
<th>ID</th>
<th>AUTHOR</th>
<th>FILENAME</th>
<th>DATEEXECUTED</th>
<th>ORDEREXECUTED</th>
<th>EXECTYPE</th>
<th>MD5SUM</th>
<th>DESCRIPTION</th>
<th>COMMENTS</th>
<th>TAG</th>
<th>LIQUIBASE</th>
<th>CONTEXTS</th>
<th>LABELS</th>
<th>DEPLOYMENT_ID</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>ashutoshkrris</td>
<td>db/changelog/changes/01-create-employees.xml</td>
<td>2026-05-30 13:11:35.937919</td>
<td>1</td>
<td>EXECUTED</td>
<td>9:66e7dcffb2b1902a4e9f01670cb5f192</td>
<td>createTable tableName=employees</td>
<td></td>
<td><em>null</em></td>
<td>4.31.1</td>
<td><em>null</em></td>
<td><em>null</em></td>
<td>0126894849</td>
</tr>
</tbody></table>
<p>Let's break down the most important columns:</p>
<ul>
<li><p><strong>ID, AUTHOR, FILENAME:</strong> These three columns form a composite key. Together, they uniquely identify a single migration.</p>
</li>
<li><p><strong>DATEEXECUTED &amp; ORDEREXECUTED:</strong> Tells you exactly when a script ran and in what sequence.</p>
</li>
<li><p><strong>MD5SUM:</strong> This is the cryptographic hash of your XML file. When Liquibase starts, it hashes your local XML file and compares it to this column. If you secretly edit a file after it's been executed, this hash won't match, and Liquibase will crash the startup to protect your database.</p>
</li>
<li><p><strong>EXECTYPE:</strong> Most of the time, this simply says <code>EXECUTED</code>. But it provides a crucial audit trail: if you use Liquibase commands to intentionally skip a migration but record it as finished, you'll see <code>MARK_RAN</code>. If a migration was skipped because its preconditions failed, you'll see <code>SKIPPED</code>.</p>
</li>
<li><p><strong>TAG:</strong> Think of this as a Git tag for your database schema. Before a major, high-risk deployment, you can configure Liquibase to "tag" the current state of the database (for example, <code>v1.4.0</code>). If the deployment fails catastrophically, you can trigger a rollback command telling Liquibase to undo every change applied after the <code>v1.4.0</code> tag.</p>
</li>
<li><p><strong>CONTEXTS:</strong> This is how you manage environment-specific changes. By adding a context attribute to your changeSet (for example, <code>&lt;changeSet id="7" author="ashutoshkrris" context="dev, qa"&gt;</code>), that migration will only execute if Spring Boot passes "dev" or "qa" to Liquibase on startup. Production will safely ignore it.</p>
</li>
<li><p><strong>LABELS:</strong> While Contexts target environments, Labels target categories of work. You can label a changeSet with a Jira ticket number (<code>issue-842</code>) or a release train (<code>Q3-release</code>). This allows advanced teams to selectively execute or roll back specific subsets of features without affecting the rest of the database.</p>
</li>
</ul>
<h3 id="heading-the-databasechangeloglock-table">The <code>DATABASECHANGELOGLOCK</code> Table</h3>
<p>This table is tiny, but it plays a massive role in modern deployments.</p>
<p>If you run <code>SELECT * FROM DATABASECHANGELOGLOCK;</code>, you'll see a single row:</p>
<table>
<thead>
<tr>
<th>ID</th>
<th>LOCKED</th>
<th>LOCKGRANTED</th>
<th>LOCKEDBY</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>FALSE</td>
<td><em>null</em></td>
<td><em>null</em></td>
</tr>
</tbody></table>
<p>Imagine you're deploying your Spring Boot application to a Kubernetes cluster. You tell Kubernetes to spin up three identical instances simultaneously. All three instances connect to the exact same database.</p>
<p>If all three instances try to run the <code>CREATE TABLE</code> migration at the exact same millisecond, your database will throw concurrency errors. The lock table prevents this. The very first instance to reach the database sets <code>LOCKED</code> to <code>TRUE</code>. The other two instances check the table, see the lock, and politely wait.</p>
<p><strong>Practical Troubleshooting Tip:</strong> Sometimes, a deployment fails catastrophically mid-migration (perhaps the server lost power). When this happens, Liquibase might die before it can set <code>LOCKED</code> back to <code>FALSE</code>.</p>
<p>The next time you start the application, the logs will hang indefinitely, repeating: <code>Waiting for changelog lock....</code></p>
<p>If you're absolutely certain no other applications are currently running migrations, you can manually fix this by running a simple SQL command in your database client:</p>
<pre><code class="language-sql">UPDATE DATABASECHANGELOGLOCK SET LOCKED = FALSE;
</code></pre>
<p>This forces the lock open, allowing your application to resume.</p>
<h2 id="heading-evolving-the-employee-api">Evolving the Employee API</h2>
<p>Software is never finished. Two weeks after your successful Version 1 deployment, the business team comes back with new requirements.</p>
<p>Because you now understand how Liquibase tracks history, evolving the database is simple. You just append new files to your master list.</p>
<h3 id="heading-version-2-adding-an-email-field">Version 2: Adding an Email Field</h3>
<p>The HR team needs to contact employees. You need an email column.</p>
<p>Create a new file at <code>src/main/resources/db/changelog/changes/02-add-employee-email.xml</code>:</p>
<pre><code class="language-xml">&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd"&gt;

    &lt;changeSet id="2" author="ashutoshkrris"&gt;
        &lt;addColumn tableName="employees"&gt;
            &lt;column name="email" type="VARCHAR(100)"&gt;
                &lt;constraints nullable="false" unique="true"/&gt;
            &lt;/column&gt;
        &lt;/addColumn&gt;
    &lt;/changeSet&gt;

&lt;/databaseChangeLog&gt;
</code></pre>
<p>Add this to your <code>db.changelog-master.xml</code> file immediately below your first include:</p>
<pre><code class="language-xml">&lt;include file="db/changelog/changes/02-add-employee-email.xml"/&gt;
</code></pre>
<p>When you restart the application, Liquibase checks the <code>DATABASECHANGELOG</code> table. It sees that <code>id="1"</code> is already there, so it skips it. It sees <code>id="2"</code> is missing, so it executes it and adds a new row to the tracking table.</p>
<h3 id="heading-version-3-adding-departments-support">Version 3: Adding Departments Support</h3>
<p>The company is growing. Employees now belong to departments. You need a <code>departments</code> table and a foreign key constraint linking the two.</p>
<p>Create <code>03-add-departments.xml</code>:</p>
<pre><code class="language-xml">&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd"&gt;

    &lt;changeSet id="3" author="ashutoshkrris"&gt;
        &lt;createTable tableName="departments"&gt;
            &lt;column name="id" type="BIGINT" autoIncrement="true"&gt;
                &lt;constraints primaryKey="true" nullable="false"/&gt;
            &lt;/column&gt;
            &lt;column name="name" type="VARCHAR(50)"&gt;
                &lt;constraints nullable="false" unique="true"/&gt;
            &lt;/column&gt;
        &lt;/createTable&gt;
    &lt;/changeSet&gt;

    &lt;changeSet id="4" author="ashutoshkrris"&gt;
        &lt;addColumn tableName="employees"&gt;
            &lt;column name="department_id" type="BIGINT"/&gt;
        &lt;/addColumn&gt;
        &lt;addForeignKeyConstraint baseTableName="employees"
                                 baseColumnNames="department_id"
                                 constraintName="fk_employee_department"
                                 referencedTableName="departments"
                                 referencedColumnNames="id"/&gt;
    &lt;/changeSet&gt;

&lt;/databaseChangeLog&gt;
</code></pre>
<p>Notice that we used two separate changeSets in one file. This is a best practice. Each changeSet represents one logical operation. If the foreign key creation (id="4") fails, the department table creation (id="3") will still be recorded as successful, and only id="4" will roll back.</p>
<h3 id="heading-version-4-amp-5-employee-status-and-performance-indexes">Version 4 &amp; 5: Employee Status and Performance Indexes</h3>
<p>Finally, HR wants to track active versus inactive staff, and the database team noticed that searching by last name is getting slow.</p>
<p>Create <code>04-status-and-indexes.xml</code>:</p>
<pre><code class="language-xml">&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd"&gt;

    &lt;changeSet id="3" author="ashutoshkrris"&gt;
        &lt;createTable tableName="departments"&gt;
            &lt;column name="id" type="BIGINT" autoIncrement="true"&gt;
                &lt;constraints primaryKey="true" nullable="false"/&gt;
            &lt;/column&gt;
            &lt;column name="name" type="VARCHAR(50)"&gt;
                &lt;constraints nullable="false" unique="true"/&gt;
            &lt;/column&gt;
        &lt;/createTable&gt;
    &lt;/changeSet&gt;

    &lt;changeSet id="4" author="ashutoshkrris"&gt;
        &lt;addColumn tableName="employees"&gt;
            &lt;column name="department_id" type="BIGINT"/&gt;
        &lt;/addColumn&gt;
        &lt;addForeignKeyConstraint baseTableName="employees"
                                 baseColumnNames="department_id"
                                 constraintName="fk_employee_department"
                                 referencedTableName="departments"
                                 referencedColumnNames="id"/&gt;
    &lt;/changeSet&gt;

&lt;/databaseChangeLog&gt;
</code></pre>
<p>Remember to add all new files to your <code>db.changelog-master.xml</code>. The order of your include statements is the exact order Liquibase will execute them.</p>
<h2 id="heading-the-golden-rule-never-modify-executed-changesets">The Golden Rule: Never Modify Executed ChangeSets</h2>
<p>Eventually, a developer on your team will look at your <code>01-create-employees.xml</code> file and notice a mistake. Perhaps they spot a typo in a column name, or perhaps they realize a column is missing a strict non-null constraint.</p>
<p>Their instinct, based on years of writing standard Java code, will be to open that XML file, fix the mistake, save the file, and restart the application.</p>
<p>Let's actually do this and see what happens.</p>
<p>Open your <code>src/main/resources/db/changelog/changes/01-create-employees.xml</code> file. Change the <code>first_name</code> column to <code>given_name</code>:</p>
<pre><code class="language-xml">&lt;column name="given_name" type="VARCHAR(50)"&gt;
    &lt;constraints nullable="false"/&gt;
&lt;/column&gt;
</code></pre>
<p>Save the file and restart your Spring Boot application.</p>
<p>Instead of a smooth startup, your application will instantly crash, and your terminal will vomit a massive stack trace. Look closely at the top of the error logs. You should see this exact message:</p>
<pre><code class="language-shell">Caused by: liquibase.exception.ValidationFailedException: Validation Failed:
     1 changesets check sum
          db/changelog/changes/01-create-employees.xml::1::ashutoshkrris was: 9:66e7dcffb2b1902a4e9f01670cb5f192 but is now: 9:2bd3ef21343d3b5c9448cc50bc35deef
</code></pre>
<p>Here's why this happens. Once a changeSet runs against an environment, it becomes immutable history. You can't change the past.</p>
<p>When Liquibase starts up, it calculates a cryptographic hash (an MD5 checksum) of your local XML file. It then queries the <code>DATABASECHANGELOG</code> table and compares the freshly calculated hash against the hash that was recorded when the file originally executed.</p>
<p>If you change even a single character in a file that has already been executed, the hash changes. Liquibase detects the tampering and refuses to start. It does this to protect your data. If your XML code says a column is named <code>first_name</code> but the database was originally built using <code>fist_name</code>, your Spring Data JPA repositories are going to fail anyway.</p>
<h3 id="heading-how-to-fix-it-the-right-way">How to Fix It (The Right Way)</h3>
<p>If you made this mistake locally, you might be tempted to go into your database, delete the row from the <code>DATABASECHANGELOG</code> table, and try again. Don't do this. If this code reaches staging or production, you can't manually delete rows on production servers.</p>
<p>The correct way to fix a schema mistake is to <strong>roll forward</strong>.</p>
<p>First, undo your change in <code>01-create-employees.xml</code> so the hash matches the database again. Then, write a brand new changeSet to apply the fix:</p>
<pre><code class="language-xml">&lt;changeSet id="7" author="ashutosh"&gt;
    &lt;renameColumn tableName="employees" 
                  oldColumnName="first_name" 
                  newColumnName="given_name" 
                  columnDataType="VARCHAR(50)"/&gt;
&lt;/changeSet&gt;
</code></pre>
<p>Include it in your master changelog, restart the application, and the database will safely evolve to the correct state.</p>
<h2 id="heading-working-with-seed-data">Working with Seed Data</h2>
<p>Sometimes, a schema change requires initial data to be useful.</p>
<p>For example, in Version 3, we created a <code>departments</code> table. Right now, that table is completely empty. When a new developer clones the repository and spins up the project locally, they have to manually write SQL <code>INSERT</code> statements just to test the API.</p>
<p>We can automate this by making baseline data insertion part of our migration strategy.</p>
<p>Create a new file at <code>src/main/resources/db/changelog/changes/05-seed-departments.xml</code>:</p>
<pre><code class="language-xml">&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd"&gt;

    &lt;changeSet id="8" author="ashutoshkrris"&gt;
        &lt;insert tableName="departments"&gt;
            &lt;column name="name" value="Engineering"/&gt;
        &lt;/insert&gt;
        &lt;insert tableName="departments"&gt;
            &lt;column name="name" value="Human Resources"/&gt;
        &lt;/insert&gt;
        &lt;insert tableName="departments"&gt;
            &lt;column name="name" value="Finance"/&gt;
        &lt;/insert&gt;
    &lt;/changeSet&gt;

&lt;/databaseChangeLog&gt;
</code></pre>
<p>Add the include statement to your <code>db.changelog-master.xml</code> file. When you restart the application, Liquibase will insert these rows. Your API is now instantly usable out of the box.</p>
<h3 id="heading-the-danger-of-data-migrations">The Danger of Data Migrations</h3>
<p>While seeding data is powerful, it requires discipline. Here is a practical engineering rule of thumb:</p>
<p><strong>Do use Liquibase for:</strong></p>
<ul>
<li><p>Static lookup tables (status codes, country lists, default departments).</p>
</li>
<li><p>System configuration flags required for the application to boot.</p>
</li>
</ul>
<p><strong>Do NOT use Liquibase for:</strong></p>
<ul>
<li><p>Generating thousands of fake users for testing.</p>
</li>
<li><p>Migrating massive amounts of transactional data (for example, moving 5 million records from one table to another).</p>
</li>
</ul>
<p>Large data migrations can lock up database tables for hours. If you lock a core table during a deployment, your application will experience a massive outage. Keep your changeSets focused on schema structure and essential baseline data. Use dedicated scripts or background jobs for heavy data manipulation.</p>
<h2 id="heading-rollbacks">Rollbacks</h2>
<p>In a perfect world, code always works. In reality, you'll eventually deploy a database change that breaks a critical production query or corrupts data. When this happens, you need a way to hit the undo button.</p>
<p>Liquibase supports rollbacks, but you have to understand how it interprets them.</p>
<h3 id="heading-automatic-vs-explicit-rollbacks">Automatic vs. Explicit Rollbacks</h3>
<p>Many Liquibase commands are automatically reversible. For example, if you write a changeSet to <code>&lt;createTable&gt;</code> or <code>&lt;addColumn&gt;</code>, Liquibase implicitly knows that the opposite of adding a column is dropping a column. You don't have to tell it how to undo these actions.</p>
<p>But some operations are inherently destructive or ambiguous. If you use custom <code>&lt;sql&gt;</code> tags, or if you use <code>&lt;dropTable&gt;</code>, Liquibase has no idea how to put the data back. In these cases, you must provide explicit rollback instructions.</p>
<p>Let's simulate a scenario where we add a temporary access code column, but we want to ensure we know exactly how to remove it safely.</p>
<p>Create <code>06-temporary-access.xml</code>:</p>
<pre><code class="language-xml">&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd"&gt;

    &lt;changeSet id="9" author="ashutosh"&gt;
        &lt;addColumn tableName="employees"&gt;
            &lt;column name="temp_access_code" type="VARCHAR(10)"/&gt;
        &lt;/addColumn&gt;
        
        &lt;rollback&gt;
            &lt;dropColumn tableName="employees" columnName="temp_access_code"/&gt;
        &lt;/rollback&gt;
    &lt;/changeSet&gt;

&lt;/databaseChangeLog&gt;
</code></pre>
<p>Add this to your master file and run the application. The column is added.</p>
<p>If you were deploying this via a CI/CD pipeline and the deployment failed, you could trigger a Liquibase Maven command to roll back by a specific number of steps (for example, <code>mvn liquibase:rollback -Dliquibase.rollbackCount=1</code>), or roll back to a specific tag we discussed earlier.</p>
<h3 id="heading-the-reality-check-on-rollbacks">The Reality Check on Rollbacks</h3>
<p>While it's important to know how rollbacks work, here's a practical reality from the trenches of backend engineering: <strong>Rollbacks are often discussed but rarely executed cleanly in production.</strong></p>
<p>Dropping a column is mathematically easy. Recovering the customer data that was written to that column during the 15 minutes the bad code was live is incredibly difficult.</p>
<p>Because of this, modern engineering teams often prefer a "roll forward" strategy. If a migration causes an issue, instead of running a scary database rollback command, they quickly write a new changeSet that fixes the issue (for example, adding a missing index or relaxing a constraint) and deploy the application again.</p>
<p>It's highly recommended to design your database changes to be additive and non-destructive to avoid needing complex rollbacks in the first place.</p>
<h2 id="heading-common-beginner-mistakes">Common Beginner Mistakes</h2>
<p>Adopting database version control is a massive step forward for any engineering team, but it comes with a learning curve. When developers transition from writing loose SQL scripts to using Liquibase, they tend to fall into a few predictable traps.</p>
<p>Here are the most common beginner mistakes and exactly how to avoid them.</p>
<h3 id="heading-1-the-mega-changeset">1. The "Mega" ChangeSet</h3>
<p>When starting out, it's tempting to dump your entire initial schema into a single XML file under a single <code>changeSet</code>. You might put 15 <code>createTable</code> statements and 20 <code>addForeignKeyConstraint</code> statements into <code>id="1"</code>.</p>
<p>This is a terrible idea for one simple reason: transaction failure.</p>
<p>If your database engine fails on table number 14 (perhaps due to a syntax error), what happens to the first 13 tables? Some database engines support transactional DDL (Data Definition Language), meaning it will roll back all 13 tables automatically. But many databases do not.</p>
<p>If it fails halfway through, your database is now in a fractured state. Liquibase didn't record <code>id="1"</code> as successful, so the next time you start the app, it will try to create all 15 tables again. It will immediately crash because table 1 already exists.</p>
<p><strong>The Fix:</strong> Stick to the rule of "one logical operation per changeSet." If you're creating three tables, write three separate changeSets. If one fails, the successful ones are permanently recorded, and you only have to fix the broken one.</p>
<h3 id="heading-2-manual-database-tweaking-the-phantom-menace">2. Manual Database Tweaking (The Phantom Menace)</h3>
<p>This is the most dangerous habit to break. A developer spots a missing index in production. Instead of writing a Liquibase migration, going through code review, and deploying, they log directly into the production database and run <code>CREATE INDEX</code> manually to save time.</p>
<p>A week later, another developer writes a proper Liquibase migration to create that exact same index and deploys it. The application crashes on startup. Liquibase tries to execute the <code>CREATE INDEX</code> command, but the database throws an error saying the index already exists.</p>
<p>When you adopt Liquibase, you must accept a fundamental rule: <strong>Liquibase is the absolute source of truth for your schema.</strong> Human hands should never touch the database structure directly.</p>
<p><strong>The Fix:</strong> If someone accidentally does this, you have two options to fix the deployment pipeline. You can manually drop the index from the database so Liquibase can recreate it properly, or you can use the <code>&lt;preConditions&gt;</code> tag in Liquibase to check if the index exists before trying to create it.</p>
<h3 id="heading-3-ignoring-the-from-scratch-build">3. Ignoring the "From Scratch" Build</h3>
<p>When you work on a project for months, your local database accumulates a lot of history. You write migrations assuming certain tables or test data already exist.</p>
<p>Then, a new developer joins the team. They pull the code, spin up an empty database, start Spring Boot, and the migrations crash halfway through.</p>
<p>This happens because the migrations rely on an assumed state (like expecting a specific row to exist before creating a foreign key) rather than a guaranteed state.</p>
<p><strong>The Fix:</strong> You should regularly test your migrations against a completely blank database. If you're using Docker, tear down your database container and rebuild it. If you're using a file-based H2 database like we set up earlier, simply delete the <code>./data/employeedb.mv.db</code> file from your project folder and restart Spring Boot. If the application can't boot successfully from a completely empty state, your migration history is broken.</p>
<h3 id="heading-4-hardcoding-environment-details">4. Hardcoding Environment Details</h3>
<p>Beginners sometimes hardcode environment-specific details directly into their XML files. For example, they might hardcode a specific schema name (schemaName="dev_schema") or grant permissions to a specific local user (GRANT ALL ON employees TO my_local_user).</p>
<p>When this code goes to staging, the staging database uses a different schema name, and the deployment fails.</p>
<p>The Fix: Keep your migrations abstract. Let Spring Boot handle the connection details via application.properties. If you absolutely must use dynamic values inside your Liquibase files, use property substitution. You can define variables in Liquibase and pass them in from Spring Boot during startup.</p>
<h3 id="heading-5-messing-up-migration-ordering">5. Messing Up Migration Ordering</h3>
<p>Liquibase executes files in the exact order they're listed in your <code>db.changelog-master.xml</code> file.</p>
<p>If developer A creates the <code>departments</code> table in a branch, and developer B creates a foreign key linking to <code>departments</code> in another branch, whoever merges their code first dictates the order. If developer B's code gets included in the master file <em>before</em> developer A's code, Liquibase will try to create the foreign key before the target table exists.</p>
<p><strong>The Fix:</strong> The master changelog is the ultimate chokepoint for database changes. During code reviews, always verify that the <code>&lt;include&gt;</code> statements are ordered chronologically and that dependencies make sense.</p>
<h2 id="heading-liquibase-vs-flyway-vs-manual-sql-scripts">Liquibase vs Flyway vs Manual SQL Scripts</h2>
<p>When you decide to implement database version control, you'll immediately face a choice. Liquibase isn't the only tool in the Java ecosystem. The three most common approaches to managing schema evolution are Liquibase, Flyway, and manual SQL scripts.</p>
<p>You should understand the practical tradeoffs of each so you can choose the right tool for your specific team and project.</p>
<h3 id="heading-1-manual-sql-scripts-the-baseline">1. Manual SQL Scripts (The Baseline)</h3>
<p>This is the default approach for most beginners. You write a script.sql file and execute it directly against the database using a tool like DBeaver, pgAdmin, or DataGrip.</p>
<ul>
<li><p><strong>Strengths:</strong> There is zero setup required. You have total control over the exact syntax, and every backend developer already knows how to write SQL.</p>
</li>
<li><p><strong>Weaknesses:</strong> There's absolutely no execution tracking. This approach practically guarantees schema drift across environments. Deployments become stressful because they rely on humans remembering to execute the right scripts in the exact right order.</p>
</li>
<li><p><strong>The Verdict:</strong> Manual scripts are perfectly fine for solo weekend projects or rapid prototyping where you don't care if the database gets destroyed. But they become a massive liability the moment a second developer joins the team or a staging environment is created.</p>
</li>
</ul>
<h3 id="heading-2-flyway-the-sql-purist">2. Flyway (The SQL Purist)</h3>
<p>Flyway is the most popular alternative to Liquibase. Instead of using XML or YAML abstractions, Flyway embraces raw SQL. You write pure SQL files with a strict naming convention (for example, V1__Create_employee_table.sql).</p>
<ul>
<li><p><strong>Strengths:</strong> There's no new syntax to learn. If you know SQL, you already know how to use Flyway. It's incredibly fast to set up, highly opinionated, and integrates flawlessly with Spring Boot.</p>
</li>
<li><p><strong>Weaknesses:</strong> Because you write raw SQL, your migrations are intimately tied to your specific database dialect. If you write Flyway scripts for MySQL and later decide to migrate the project to PostgreSQL, you have to manually rewrite your migration history. Furthermore, seamless automated rollbacks are a paid feature in Flyway's commercial tier.</p>
</li>
<li><p><strong>The Verdict:</strong> Flyway is excellent for teams that are highly skilled in SQL, are permanently committed to a single database vendor, and prefer strict conventions over flexible configurations.</p>
</li>
</ul>
<h3 id="heading-3-liquibase-the-abstraction-layer">3. Liquibase (The Abstraction Layer)</h3>
<p>As we have seen throughout this tutorial, Liquibase takes a different approach by abstracting database changes into XML, YAML, or JSON.</p>
<ol>
<li><p><strong>Strengths:</strong> It's truly database-agnostic. You define the logical structure, and Liquibase automatically translates that into the correct SQL dialect for H2, PostgreSQL, or Oracle. It supports powerful automatic rollbacks, preconditions, contexts, and deployment labels out of the box for free.</p>
</li>
<li><p><strong>Weaknesses:</strong> It has a steeper learning curve than Flyway. The XML syntax is undeniably verbose and can feel heavy for very simple, single-table applications.</p>
</li>
<li><p><strong>The Verdict:</strong> Liquibase shines in complex applications, multi-tenant systems, projects that support multiple database vendors, and enterprise environments that require fine-grained control over CI/CD deployment pipelines.</p>
</li>
</ol>
<h2 id="heading-liquibase-best-practices">Liquibase Best Practices</h2>
<p>Now that you understand the mechanics of Liquibase, you need to know how to use it in a professional environment. Writing a migration that works on your local machine is only half the battle. Writing a migration that your entire team can safely deploy to production requires discipline.</p>
<p>Here are the engineering best practices you should adopt when managing database changes.</p>
<h3 id="heading-1-one-logical-change-per-changeset-the-atomic-rule">1. One Logical Change Per ChangeSet (The Atomic Rule)</h3>
<p>We discussed this in the common mistakes section, but it's important enough to repeat. Never bundle a table creation, an index creation, and a data insertion into a single changeSet.</p>
<p>If you're adding a salary column and an idx_employee_salary index, put them in two separate changeSets within the same file. This ensures that if the index creation fails, the column creation is still safely recorded, and you don't end up in a fractured database state.</p>
<h3 id="heading-2-meaningful-file-organization-and-naming">2. Meaningful File Organization and Naming</h3>
<p>Don't name your files <code>update1.xml</code> or <code>new_changes.xml</code>. Your file names should tell a story about how your database evolved.</p>
<p>Adopt a strict prefix system. In our project, we used <code>01-create-employees.xml</code> and <code>02-add-employee-email.xml</code>. In a real team, you might use Jira ticket numbers or release versions (for example, <code>v1.2.0_ticket-482_add_email.xml</code>). Whatever convention you choose, enforce it rigorously during code reviews.</p>
<h3 id="heading-3-treat-database-changes-like-application-code">3. Treat Database Changes Like Application Code</h3>
<p>Database migrations belong in source control right next to your Java code. They should be reviewed with the exact same level of scrutiny.</p>
<p>When reviewing a pull request that includes a Liquibase file, engineers should ask:</p>
<ul>
<li><p>Does this column need an index?</p>
</li>
<li><p>Is this a destructive change (like renaming a column) that will break the currently running application?</p>
</li>
<li><p>Did the author include explicit rollback instructions for custom SQL?</p>
</li>
</ul>
<h3 id="heading-4-integrate-migrations-into-cicd">4. Integrate Migrations into CI/CD</h3>
<p>Human hands should never run database migrations against a production server. Your deployment pipeline should handle this automatically.</p>
<p>When you merge code into your main branch, your CI/CD pipeline (like GitHub Actions or GitLab CI) should build your Spring Boot application and deploy it. Because we bundled Liquibase into our Spring Boot startup sequence, the application will automatically migrate the production database before it starts accepting web traffic.</p>
<p>Here's what a safe, automated deployment pipeline looks like:</p>
<img src="https://cdn.hashnode.com/uploads/covers/61c1acb4a90dea775da8262b/7a9323e6-c44a-4ee5-8307-3ee8a696c3a9.png" alt="CI/CD pipeline architecture showing code moving from Git to testing, deploying to a staging environment where Liquibase runs, and then promoting to production." style="display:block;margin:0 auto" width="677" height="657" loading="lazy">

<p>In a mature deployment pipeline, human hands never touch the production database. When you merge a pull request, the CI/CD pipeline builds the code and runs unit tests. It deploys the Spring Boot application to a staging environment, where Liquibase automatically acquires a lock and runs the migrations during startup. Once validated, that exact same artifact is promoted to production, triggering the identical automated migration process.</p>
<h3 id="heading-5-never-fix-forward-by-deleting-history">5. Never Fix Forward by Deleting History</h3>
<p>If a migration fails in an upper environment (like staging or production), never log into the database to delete the <code>DATABASECHANGELOG</code> row so you can try again.</p>
<p>You must respect the immutability of the changelog. If you made a mistake, write a new changeSet that drops the broken table or fixes the data type, and push it through your Git workflow just like you would a Java bug fix.</p>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>Managing database schema changes doesn't have to be a source of anxiety.</p>
<p>By treating your database schema as code, you eliminate the chaos of manual SQL scripts. You prevent the dreaded "schema drift" where every developer's local machine behaves differently. Most importantly, you make your deployments predictable and boring (which is exactly what you want deployments to be).</p>
<p>In this tutorial, you built a practical Spring Boot application from scratch. You learned how Liquibase intercepts the application startup, locks the database, calculates cryptographic checksums, and safely applies incremental changes. You evolved a single table into a relational schema, added seed data, and learned how to avoid the most common traps beginners fall into.</p>
<p>The next time you start a Spring Boot project, don't reach for a manual SQL client. Add the Liquibase dependency, create your master changelog, and start version controlling your database from day one. Your future self (and your team) will thank you.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Live Options Database in Python – A Complete Guide ]]>
                </title>
                <description>
                    <![CDATA[ Live options analytics change constantly. Implied volatility shifts, Greeks drift, and the shape of the surface can look different even a few minutes later. But a lot of teams still treat these number ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-live-options-database-in-python-a-complete-guide/</link>
                <guid isPermaLink="false">69fd19789f93a850a43041c9</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Databases ]]>
                    </category>
                
                    <category>
                        <![CDATA[ stockmarket ]]>
                    </category>
                
                    <category>
                        <![CDATA[ trading,  ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikhil Adithyan ]]>
                </dc:creator>
                <pubDate>Thu, 07 May 2026 23:00:08 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/4ecffa99-c492-4959-9899-885021d11ee4.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Live options analytics change constantly. Implied volatility shifts, Greeks drift, and the shape of the surface can look different even a few minutes later.</p>
<p>But a lot of teams still treat these numbers like something you glance at once. A screenshot in a deck. A one-off notebook cell. A quick check in a UI before a meeting.</p>
<p>That works until you need to answer basic questions that show up in real workflows:</p>
<p>What did TSLA's surface look like at 10:32? When did skew start steepening? Did the change come from the wings moving or the ATM shifting?</p>
<p>If you don't store the data as it arrives, you can't replay it, compare it, or audit it. You're stuck with whatever you happened to look at in the moment.</p>
<p>In this walkthrough, we'll build something small but practical: an internal database that continuously captures SpiderRock MLink's LiveImpliedQuote analytics for TSLA, stores each snapshot as queryable history, and also maintains a "latest view" table so you can pull the current surface state without scanning the full history.</p>
<p><strong>The goal is not to build a trading system. It's to build a reliable internal dataset that you can monitor and query.</strong></p>
<p>Note: SpiderRock MLink's LiveImpliedQuote analytics is a product offered for a fee, which includes exchange charges for the underlying market data used in its creation.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-data-were-using">What Data We're Using</a></p>
</li>
<li><p><a href="#heading-setup-importing-packages">Setup: Importing Packages</a></p>
</li>
<li><p><a href="#heading-database-design">Database Design</a></p>
</li>
<li><p><a href="#heading-pulling-liveimpliedquote">Pulling LiveImpliedQuote</a></p>
</li>
<li><p><a href="#heading-normalizing-the-response-into-rows">Normalizing the Response Into Rows</a></p>
</li>
<li><p><a href="#heading-writing-to-the-database">Writing To The Database</a></p>
</li>
<li><p><a href="#heading-running-a-short-polling-capture">Running a Short Polling Capture</a></p>
</li>
<li><p><a href="#heading-analysis-smile-reconstruction-from-the-database">Analysis: Smile Reconstruction From the Database</a></p>
<ul>
<li><p><a href="#heading-pick-an-expiry-with-good-coverage">Pick an Expiry with Good Coverage</a></p>
</li>
<li><p><a href="#heading-rebuild-the-smile-across-snapshots">Rebuild the Smile Across Snapshots</a></p>
</li>
<li><p><a href="#heading-zoom-in-around-spot">Zoom-In Around Spot</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-analysis-atm-iv-and-skew-over-time">Analysis: ATM IV and Skew Over Time</a></p>
</li>
<li><p><a href="#heading-alert-style-thresholds">Alert-Style Thresholds</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before running any of the code in this walkthrough, there are a few things you need to have in place.</p>
<p>On the API side, you need a SpiderRock MLink account with access to the LiveImpliedQuote feed. The examples use the REST interface, so no websocket setup is required, but you do need a valid API key. If you don't have one yet, you can reach out to SpiderRock directly to get access.</p>
<p>On the Python side, the environment is minimal. You need Python 3.10 or later for the tuple type hint syntax used in one of the function signatures. The external packages are requests, pandas, numpy, and matplotlib. Everything else – sqlite3, time, datetime – is part of the standard library. You can install the external dependencies with:</p>
<pre><code class="language-plaintext">pip install requests pandas numpy matplotlib
</code></pre>
<p>No database setup is required beyond a writable local path. SQLite creates the file automatically on first run, so there's nothing to install or configure separately.</p>
<p>Finally, the walkthrough uses TSLA as the target symbol because it has a liquid and active options chain. If you want to swap in a different underlying, the only thing you need to change is the symbol variable in the config block.</p>
<h2 id="heading-what-data-were-using">What Data We're Using</h2>
<p>This build is driven by one OptAnalytics message type from SpiderRock MLink: <a href="https://docs.spiderrockconnect.com/docs/next/MessageSchemas/Schema/Topics/analytics/LiveImpliedQuote/"><strong>LiveImpliedQuote</strong></a>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/7150e733-6238-410b-afe7-abc781d67e7a.png" alt="LiveImpliedQuote docs page" style="display:block;margin:0 auto" width="1000" height="451" loading="lazy">

<p>Each message represents an option contract and comes with the analytics you actually need for monitoring:</p>
<ul>
<li><p>the option identifier (symbol, expiry, strike, call or put)</p>
</li>
<li><p>surface IV (sVol) and related surface fields</p>
</li>
<li><p>Greeks (delta, gamma, theta, vega)</p>
</li>
<li><p>context fields like underlying price (uPrc), time to expiry (years), and rate (rate)</p>
</li>
<li><p>timestamps and calc source markers, which matter when you're turning a live feed into a database</p>
</li>
</ul>
<p>We'll treat sVol as the main volatility field for the article and refer to it as surface IV. That keeps the workflow consistent when we rebuild smiles or compute skew proxies from stored history.</p>
<p>The demo uses TSLA because it has a rich and active options chain, which makes the database and queries more interesting even in a short capture window. The same pipeline works for any other underlying&nbsp;– the only thing you change is the symbol filter.</p>
<h2 id="heading-setup-importing-packages">Setup: Importing Packages</h2>
<p>Before touching the database or the API, we set up a small, repeatable environment. This section is intentionally minimal. We only import what we need for three things: making REST calls, storing data in SQLite, and doing basic analysis and plots.</p>
<pre><code class="language-python">import requests
import sqlite3
import pandas as pd
import numpy as np
import time
from datetime import datetime, timezone
import matplotlib.pyplot as plt
plt.style.use('ggplot')
</code></pre>
<ul>
<li><p><code>requests</code> is used for calling MLink REST endpoints.</p>
</li>
<li><p><code>sqlite3</code> gives us a lightweight database we can write to locally without extra setup.</p>
</li>
<li><p><code>pandas</code> and <code>numpy</code> are only for shaping and filtering the data once it comes back.</p>
</li>
<li><p><code>time</code> and <code>datetime</code> help us run a polling loop and timestamp each snapshot so the database becomes a real-time series.</p>
</li>
</ul>
<h2 id="heading-database-design">Database Design</h2>
<p>If the goal is to make live analytics queryable, the database design has to support two different needs.</p>
<p>First, you want an audit trail. Every snapshot should be preserved so you can reconstruct what the surface looked like at a specific time.</p>
<p>Second, you also want a fast way to answer "what does it look like right now" without scanning everything you've ever stored.</p>
<p>So we use two tables:</p>
<ul>
<li><p><code>implied_quote_history</code>: Append-only. Every poll inserts a full snapshot.</p>
</li>
<li><p><code>implied_quote_latest</code>: One row per option contract. Each poll upserts into this table so it always reflects the most recent snapshot.</p>
</li>
</ul>
<p>The core of both tables is a stable option identifier. In the feed, the option key is nested, so we normalize it into a single <code>option_key</code> string that includes symbol, expiry, strike, call or put, and venue fields. This becomes the primary key for the latest table and the main join key for queries.</p>
<pre><code class="language-python">#config
api_key = "YOUR SPIDERROCK API KEY"
mlink_url = "https://mlink-live.nms.saturn.spiderrockconnect.com/rest/json"

msg_type = "LiveImpliedQuote"

symbol = "TSLA"
poll_interval_s = 10
poll_duration_s = 120
limit = 2000

#create db connection
db_path = "/mnt/data/optanalytics_iv_greeks.db"

def get_conn(path: str = db_path):
    conn = sqlite3.connect(path)
    conn.execute("PRAGMA journal_mode=WAL;")
    conn.execute("PRAGMA synchronous=NORMAL;")
    return conn

#create db schema
def setup_db(path: str = db_path):
    conn = get_conn(path)
    cur = conn.cursor()

    cur.execute("""
    create table if not exists implied_quote_history (
        id integer primary key autoincrement,
        asof_ts text not null,

        option_key text not null,
        symbol text not null,
        expiry text not null,
        strike real not null,
        cp text not null,

        calc_source text,
        u_prc real,
        years real,
        rate real,

        s_vol real,
        atm_vol real,
        s_mark real,

        o_bid real,
        o_ask real,
        o_bid_iv real,
        o_ask_iv real,

        delta real,
        gamma real,
        theta real,
        vega real,

        src_ts text
    );
    """)

    cur.execute("""
    create index if not exists idx_hist_symbol_expiry_asof
    on implied_quote_history(symbol, expiry, asof_ts);
    """)

    cur.execute("""
    create index if not exists idx_hist_option_asof
    on implied_quote_history(option_key, asof_ts);
    """)

    cur.execute("""
    create table if not exists implied_quote_latest (
        option_key text primary key,

        last_asof_ts text not null,
        symbol text not null,
        expiry text not null,
        strike real not null,
        cp text not null,

        calc_source text,
        u_prc real,
        years real,
        rate real,

        s_vol real,
        atm_vol real,
        s_mark real,

        o_bid real,
        o_ask real,
        o_bid_iv real,
        o_ask_iv real,

        delta real,
        gamma real,
        theta real,
        vega real,

        src_ts text
    );
    """)

    cur.execute("""
    create index if not exists idx_latest_symbol_expiry
    on implied_quote_latest(symbol, expiry);
    """)

    conn.commit()
    conn.close()

setup_db()
</code></pre>
<p>This creates the SQLite database file and both tables. The history table is append-only and indexed for the two queries we'll run later: pulling snapshots by expiry and time, and pulling a specific option's timeline by <code>option_key</code>. The latest table is keyed by <code>option_key</code>, which lets us upsert and maintain a consistent "current view."</p>
<p>The columns we store are intentionally opinionated. We keep surface IV (s_vol), surface mark (s_mark), Greeks, and a few context fields. We also store timestamps so later we can reason about when a value was produced.</p>
<h2 id="heading-pulling-liveimpliedquote">Pulling LiveImpliedQuote</h2>
<p>Now we do the first live pull. The goal here is not to build a perfect filter. It's to confirm that we can retrieve a meaningful slice of TSLA option analytics and that the response structure is what we expect.</p>
<p>We request LiveImpliedQuote and filter by symbol using the where clause. The response is a list where most rows are actual LiveImpliedQuote messages, and one row at the end is a QueryResult summary.</p>
<pre><code class="language-python">def fetch_live_implied_quote(symbol: str, limit: int = 2000):
    where = f"okey.tk:eq:{symbol}"

    params = {
        "apiKey": api_key,
        "cmd": "getmsgs",
        "msgType": msg_type,
        "where": where,
        "limit": limit
    }

    r = requests.get(mlink_url, params=params)
    r.raise_for_status()
    return r.json()

raw = fetch_live_implied_quote(symbol, limit=limit)
print("raw messages:", len(raw))
print("first type:", raw[0].get("header", {}).get("mTyp") if raw else None)
</code></pre>
<p>This is a straight REST <code>getmsgs</code> call. We pass the API key, message type, and a simple symbol filter. The <code>limit</code> is important. It caps how many messages we get back in one poll, so for active underlyings, the returned set of strikes and expiries can vary between polls. That's fine for this tutorial, because the goal is to show the database pattern and the types of monitoring queries it enables.</p>
<p>This is the output you should see:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/606259cd-e6ed-4f6f-b24f-48fafe9c561b.png" alt="LiveImpliedQuote sample pull" style="display:block;margin:0 auto" width="988" height="170" loading="lazy">

<h2 id="heading-normalizing-the-response-into-rows">Normalizing the Response Into Rows</h2>
<p>Right now, raw is a list of nested message objects. That format is fine for transport, but it's not something you can store or query directly. So now, we turn each LiveImpliedQuote message into one flat row with a consistent schema.</p>
<pre><code class="language-python">def make_option_key(okey: dict) -&gt; str:
    return "|".join([
        str(okey.get("tk")),
        str(okey.get("dt")),
        str(okey.get("xx")),
        str(okey.get("cp")),
        str(okey.get("at")),
        str(okey.get("ts")),
    ])

def normalize_liq(raw: list, asof_ts: str, keep_calc_source: str = "Loop") -&gt; pd.DataFrame:
    rows = []

    for row in raw:
        if row.get("header", {}).get("mTyp") != "LiveImpliedQuote":
            continue

        m = row.get("message", {})
        if keep_calc_source and m.get("calcSource") != keep_calc_source:
            continue

        pkey = m.get("pkey", {})
        okey = pkey.get("okey", {})
        if not okey:
            continue

        s_vol = m.get("sVol")
        if s_vol is None or s_vol == 0:
            continue

        o_bid = m.get("oBid", 0) or 0
        o_ask = m.get("oAsk", 0) or 0

        quote_ok = int(not (o_bid == 0 and o_ask == 0))

        rows.append({
            "asof_ts": asof_ts,
            "option_key": make_option_key(okey),

            "symbol": okey.get("tk"),
            "expiry": okey.get("dt"),
            "strike": okey.get("xx"),
            "cp": okey.get("cp"),

            "calc_source": m.get("calcSource"),
            "u_prc": m.get("uPrc"),
            "years": m.get("years"),
            "rate": m.get("rate"),

            "s_vol": s_vol,
            "atm_vol": m.get("atmVol"),
            "s_mark": m.get("sMark"),

            "o_bid": o_bid,
            "o_ask": o_ask,
            "o_bid_iv": m.get("oBidIv"),
            "o_ask_iv": m.get("oAskIv"),
            "quote_ok": quote_ok,

            "delta": m.get("de"),
            "gamma": m.get("ga"),
            "theta": m.get("th"),
            "vega": m.get("ve"),

            "src_ts": m.get("timestamp"),
        })

    df = pd.DataFrame(rows)
    if df.empty:
        return df

    df = (
        df.sort_values("src_ts")
          .drop_duplicates(subset=["option_key"], keep="last")
          .reset_index(drop=True)
    )
    return df

asof_ts = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
snapshot_df = normalize_liq(raw, asof_ts)

print("snapshot rows:", len(snapshot_df))
print("quote_ok distribution:", snapshot_df["quote_ok"].value_counts().to_dict() if not snapshot_df.empty else {})
snapshot_df.head()
</code></pre>
<p>There are three practical decisions baked into this normalization step:</p>
<ul>
<li><p>First, we build a stable <code>option_key</code> from the option identifier so we have a consistent primary key for the latest table.</p>
</li>
<li><p>Second, we keep only <code>calcSource="Loop"</code>. LiveImpliedQuote can include both Tick and Loop records. Loop records tend to be more consistent for snapshot-style analysis because the underlying reference price is stable across the surface.</p>
</li>
<li><p>Third, we avoid aggressive filtering. In this dataset, the top-of-book bid and ask fields can be zero even when the analytics fields are populated. So instead of dropping those rows, we store a <code>quote_ok</code> flag and keep the record. That keeps the pipeline usable while still making it obvious later which rows had live quotes.</p>
</li>
</ul>
<p>This is the output:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/7d04a9e8-d3ec-4737-a0a7-64cb3888380c.png" alt="LiveImpliedQuote snapshot" style="display:block;margin:0 auto" width="1500" height="496" loading="lazy">

<p>At this point, one row represents one option contract snapshot. The fact that <code>quote_ok</code> is 0 across the board simply means bid and ask are not populated in this slice, even though surface IV, Greeks, and other analytics fields are present. That's still useful for building a monitoring database, because the core idea here is tracking the evolution of analytics over time, not reconstructing executable markets.</p>
<h2 id="heading-writing-to-the-database">Writing to the Database</h2>
<p>Now that we have a clean snapshot DataFrame, the job is to persist it in two places.</p>
<p>History table: Append everything. This is the audit log. Latest table: Upsert by <code>option_key</code>. This is the fast "current view."</p>
<p>This separation is what makes the database useful. History lets you reconstruct any past snapshot. Latest lets you answer "what does the surface look like right now" without scanning time series.</p>
<pre><code class="language-python">def safe_add_column(table: str, col: str, col_type: str, path: str = db_path):
    conn = get_conn(path)
    cur = conn.cursor()
    existing = [r[1] for r in cur.execute(f"PRAGMA table_info({table});").fetchall()]
    if col not in existing:
        cur.execute(f"ALTER TABLE {table} ADD COLUMN {col} {col_type};")
    conn.commit()
    conn.close()

safe_add_column("implied_quote_history", "quote_ok", "INTEGER")
safe_add_column("implied_quote_latest", "quote_ok", "INTEGER")

def write_snapshot_to_db(df: pd.DataFrame, path: str = db_path) -&gt; tuple[int, int]:
    if df.empty:
        return 0, 0

    conn = get_conn(path)
    cur = conn.cursor()

    cols = [
        "asof_ts",
        "option_key","symbol","expiry","strike","cp",
        "calc_source","u_prc","years","rate",
        "s_vol","atm_vol","s_mark",
        "o_bid","o_ask","o_bid_iv","o_ask_iv",
        "delta","gamma","theta","vega",
        "quote_ok","src_ts"
    ]

    for c in cols:
        if c not in df.columns:
            df[c] = None

    insert_df = df[cols].copy()

    cur.executemany(
        """
        insert into implied_quote_history (
            asof_ts,
            option_key, symbol, expiry, strike, cp,
            calc_source, u_prc, years, rate,
            s_vol, atm_vol, s_mark,
            o_bid, o_ask, o_bid_iv, o_ask_iv,
            delta, gamma, theta, vega,
            quote_ok, src_ts
        ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        """,
        insert_df.itertuples(index=False, name=None)
    )
    history_inserted = cur.rowcount

    cur.executemany(
        """
        insert into implied_quote_latest (
            option_key,
            last_asof_ts, symbol, expiry, strike, cp,
            calc_source, u_prc, years, rate,
            s_vol, atm_vol, s_mark,
            o_bid, o_ask, o_bid_iv, o_ask_iv,
            delta, gamma, theta, vega,
            quote_ok, src_ts
        ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        on conflict(option_key) do update set
            last_asof_ts=excluded.last_asof_ts,
            symbol=excluded.symbol,
            expiry=excluded.expiry,
            strike=excluded.strike,
            cp=excluded.cp,
            calc_source=excluded.calc_source,
            u_prc=excluded.u_prc,
            years=excluded.years,
            rate=excluded.rate,
            s_vol=excluded.s_vol,
            atm_vol=excluded.atm_vol,
            s_mark=excluded.s_mark,
            o_bid=excluded.o_bid,
            o_ask=excluded.o_ask,
            o_bid_iv=excluded.o_bid_iv,
            o_ask_iv=excluded.o_ask_iv,
            delta=excluded.delta,
            gamma=excluded.gamma,
            theta=excluded.theta,
            vega=excluded.vega,
            quote_ok=excluded.quote_ok,
            src_ts=excluded.src_ts
        """,
        insert_df[[
            "option_key","asof_ts","symbol","expiry","strike","cp",
            "calc_source","u_prc","years","rate",
            "s_vol","atm_vol","s_mark",
            "o_bid","o_ask","o_bid_iv","o_ask_iv",
            "delta","gamma","theta","vega",
            "quote_ok","src_ts"
        ]].itertuples(index=False, name=None)
    )
    latest_upserted = cur.rowcount

    conn.commit()
    conn.close()
    return history_inserted, latest_upserted

hist_n, latest_n = write_snapshot_to_db(snapshot_df)
print("history inserted:", hist_n)
print("latest upserted:", latest_n)
</code></pre>
<p>We batch write using <code>executemany</code> so inserts are fast even with thousands of option rows. The history insert is straightforward. The latest write uses a SQLite upsert keyed on <code>option_key</code>, which means if the contract already exists in the latest table, its fields are overwritten with the newest snapshot.</p>
<p>You should see:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/8fdbdeb1-a4f2-434d-a3c7-99f44e51ec5d.png" alt="History inserted: 1852, latest upserted: 1852" style="display:block;margin:0 auto" width="608" height="137" loading="lazy">

<p>After the first write, both tables have the same number of rows. That's expected, because there is only one snapshot in history so far. Once we start polling multiple snapshots, the history table will grow every cycle, while the latest table will stay roughly flat and continue updating in place.</p>
<h2 id="heading-running-a-short-polling-capture">Running a Short Polling Capture</h2>
<p>At this point, the pipeline works end-to-end for a single snapshot. The whole point of the database, though, is to turn live analytics into a time series. So we run a short capture window and store multiple snapshots back-to-back.</p>
<p>This isn't meant to be a production scheduler. It's just a simple loop that runs for a couple of minutes, polls every few seconds, timestamps the snapshot, and writes it to both tables.</p>
<pre><code class="language-python">def poll_and_write(symbol: str, duration_s: int = poll_duration_s, interval_s: int = poll_interval_s):
    start = time.time()
    polls = 0
    total_hist = 0

    while time.time() - start &lt; duration_s:
        asof_ts = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")

        raw = fetch_live_implied_quote(symbol, limit=limit)
        df = normalize_liq(raw, asof_ts)

        hist_n, latest_n = write_snapshot_to_db(df)
        polls += 1
        total_hist += hist_n

        print(f"[{polls}] {asof_ts} snapshot_rows={len(df)} history+={hist_n} latest_upsert={latest_n}")
        time.sleep(interval_s)

    print(f"done. polls={polls}, total_history_added={total_hist}")

poll_and_write(symbol, duration_s=120, interval_s=10)
</code></pre>
<p>Each loop iteration represents one snapshot. We generate a UTC timestamp (asof_ts), pull the latest batch from LiveImpliedQuote, normalize it into rows, then write it into the database. The history table accumulates every snapshot. The latest table overwrites by <code>option_key</code>, so it always represents the most recent view.</p>
<p>One practical detail is worth calling out. The API call is capped by limit, so you're not guaranteed to receive an identical set of strikes and expiries every poll. That's why <code>snapshot_rows</code> can vary between iterations.</p>
<p>In production, you usually stabilize the slice by pinning specific expiries and a strike band or by interpolating IV to fixed moneyness points. For this tutorial, we're keeping ingestion simple and focusing on the database pattern and the monitoring queries it enables.</p>
<p>You should see per-poll telemetry like this:</p>
<pre><code class="language-plaintext">[1] 2026-04-14T18:09:29Z snapshot_rows=1454 history+=1454 latest_upsert=1454
...
done. polls=9, total_history_added=12806
</code></pre>
<p>This confirms the database is building a time series. Over nine polls, you stored 12,806 option rows in history. The latest table is updated each time, but it doesn't grow in the same way as history because it overwrites per contract key.</p>
<p>From the next section, we'll stop writing and start querying.</p>
<h2 id="heading-analysis-smile-reconstruction-from-the-database">Analysis: Smile Reconstruction From the Database</h2>
<p>Once the data is in <code>implied_quote_history</code>, the workflow flips. We stop thinking in terms of "API responses" and start thinking in terms of "queries." This section does two things. First, it picks an expiry that has enough rows to be representative. Then it reconstructs the call-side volatility smile for that expiry across a few timestamps.</p>
<h3 id="heading-pick-an-expiry-with-good-coverage">Pick an Expiry with Good Coverage</h3>
<p>If you pick an expiry that only appears sporadically in the captured snapshots, the smile plot will be misleading. So we start by looking at which expiries have the most rows in the history table.</p>
<pre><code class="language-python">conn = get_conn()

expiry_counts = pd.read_sql_query(
    """
    select expiry, count(*) as n
    from implied_quote_history
    where symbol = ?
    group by expiry
    order by n desc
    limit 10
    """,
    conn,
    params=(symbol,)
)

conn.close()
expiry_counts
</code></pre>
<p>This query scans only the history table, filters to TSLA, and counts how many option rows exist per expiry across the capture window. We keep the top 10 and pick the first one as the expiry we'll reconstruct.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/2f7b897f-0a4f-4b1a-826e-0fee6b19f2bd.png" alt="Expiry-wise coverage" style="display:block;margin:0 auto" width="373" height="724" loading="lazy">

<p>The expiry date <code>2026-11-20</code> has the highest count.</p>
<p>Here, the count doesn't mean this expiry is "best" in any trading sense. It just means it showed up most consistently in the captured data. That makes it a practical choice for a clean smile comparison.</p>
<h3 id="heading-rebuild-the-smile-across-snapshots">Rebuild the Smile Across Snapshots</h3>
<p>Now we query the stored history for one expiry, keep only calls, and plot surface IV (s_vol) against strike for multiple snapshot timestamps.</p>
<pre><code class="language-python">chosen_expiry = "2026-11-20" 

conn = get_conn()
smile = pd.read_sql_query(
    """
    select asof_ts, strike, cp, s_vol, u_prc
    from implied_quote_history
    where symbol = ? and expiry = ?
    """,
    conn,
    params=(symbol, chosen_expiry)
)
conn.close()

smile_calls = smile[smile["cp"] == "Call"].copy()

ts_list = sorted(smile_calls["asof_ts"].unique())
pick = [ts_list[0], ts_list[len(ts_list)//2], ts_list[-1]]

plt.figure(figsize=(9,5))
for ts in pick:
    g = smile_calls[smile_calls["asof_ts"] == ts].sort_values("strike")
    plt.plot(g["strike"], g["s_vol"], label=ts)

plt.title(f"{symbol} Vol Smile (Calls) | Expiry {chosen_expiry} | 3 snapshots")
plt.xlabel("Strike")
plt.ylabel("Implied Vol (s_vol)")
plt.grid(True)
plt.legend()
plt.show()
</code></pre>
<p>We pull all rows for the chosen expiry from history, then filter to calls so we don't mix put and call shapes. To keep the plot readable, we only plot three snapshots. First, middle, and last.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/84416f80-9253-4f18-8da4-ea814e174987.png" alt="TSLA vol smile (calls)" style="display:block;margin:0 auto" width="778" height="475" loading="lazy">

<p>Over a short capture window, the smiles often overlap heavily. That doesn't mean the system isn't working. It usually means the surface didn't move much in those two minutes. The important part is that we can reconstruct and compare it purely from stored history.</p>
<h3 id="heading-zoom-in-around-spot">Zoom-In Around Spot</h3>
<p>The full-range plot is useful for shape, but it can hide small shifts near the region people actually care about. So we zoom to a band around the underlying price.</p>
<pre><code class="language-python">s0 = float(smile_calls["u_prc"].dropna().median())
low, high = s0 * 0.6, s0 * 1.4

for ts in pick:
    g = smile_calls[smile_calls["asof_ts"] == ts].sort_values("strike")
    g = g[(g["strike"] &gt;= low) &amp; (g["strike"] &lt;= high)]
    plt.plot(g["strike"], g["s_vol"], label=ts)

plt.title(f"{symbol} Vol Smile (Calls) | Expiry {chosen_expiry} | zoomed")
plt.xlabel("Strike")
plt.ylabel("Implied Vol (s_vol)")
plt.grid(True)
plt.legend(fontsize=8)
plt.show()
</code></pre>
<p>We take a robust spot proxy from the stored <code>u_prc</code> values and then keep strikes within a range around it. The goal is not precision. It's to make the chart readable and show whether the near-ATM region is drifting.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/107de4b4-7b40-4e79-a38b-fac96cb11b26.png" alt="TSLA vol smile (calls)  -  zoomed-in" style="display:block;margin:0 auto" width="781" height="475" loading="lazy">

<p>Here, even small changes become visible. This is also why storing history matters. If you only looked at one snapshot in isolation, these shifts would be easy to miss or dismiss.</p>
<h2 id="heading-analysis-atm-iv-and-skew-over-time">Analysis: ATM IV and Skew Over Time</h2>
<p>A full smile plot is useful, but it's not always the fastest way to monitor a surface. In practice, teams usually track a few summary numbers per expiry so they can spot changes quickly, then drill down only when something looks off.</p>
<p>Here we reduce each stored snapshot into two metrics for a single expiry.</p>
<ul>
<li><p>ATM IV: Surface IV at the strike closest to spot.</p>
</li>
<li><p>Skew proxy: Surface IV at 0.9 times spot minus surface IV at 1.1 times spot, using the closest available strikes.</p>
</li>
</ul>
<pre><code class="language-python">chosen_expiry = "2026-11-20"

conn = get_conn()
df = pd.read_sql_query(
    """
    select asof_ts, strike, s_vol, u_prc
    from implied_quote_history
    where symbol = ? and expiry = ? and cp = 'Call'
    """,
    conn,
    params=(symbol, chosen_expiry)
)
conn.close()

df["strike"] = df["strike"].astype(float)
df["s_vol"] = df["s_vol"].astype(float)

def closest_iv(grp: pd.DataFrame, target_strike: float):
    g = grp.iloc[(grp["strike"] - target_strike).abs().argsort()[:1]]
    return float(g["s_vol"].iloc[0]), float(g["strike"].iloc[0])

rows = []
for ts, grp in df.groupby("asof_ts"):
    spot = float(grp["u_prc"].dropna().median())
    atm_target = spot
    down_target = spot * 0.9
    up_target = spot * 1.1

    atm_iv, atm_k = closest_iv(grp, atm_target)
    down_iv, down_k = closest_iv(grp, down_target)
    up_iv, up_k = closest_iv(grp, up_target)

    rows.append({
        "asof_ts": ts,
        "spot": spot,
        "atm_strike": atm_k,
        "atm_iv": atm_iv,
        "k90": down_k,
        "iv_90": down_iv,
        "k110": up_k,
        "iv_110": up_iv,
        "skew_90_110": down_iv - up_iv
    })

metrics = pd.DataFrame(rows).sort_values("asof_ts").reset_index(drop=True)
metrics
</code></pre>
<p>We query the history table for one expiry and keep only calls, then group by snapshot timestamp. For each snapshot, we use the median <code>u_prc</code> as a spot proxy and pick the closest available strike to spot. That gives ATM IV. We repeat the same approach for 0.9 times spot and 1.1 times spot and compute a skew proxy as the difference.</p>
<p>The table also stores the actual strikes used (atm_strike, k90, k110). Options strikes are discrete, so the nearest strike can change between snapshots. Keeping the chosen strikes visible makes the metric explainable when it moves.</p>
<p>The output is a table with one row per snapshot timestamp and the computed metrics.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/5590b162-5fe7-4713-8f56-edc4c6171ab2.png" alt="ATM IV, skew proxy metrics" style="display:block;margin:0 auto" width="1000" height="441" loading="lazy">

<p>Now that we have a clean time series table, we can visualize the two metrics. First, ATM IV. Then, the skew proxy.</p>
<pre><code class="language-python">plt.plot(metrics["asof_ts"], metrics["atm_iv"])
plt.title(f"{symbol} ATM IV over time | Expiry {chosen_expiry}")
plt.xticks(rotation=30, ha="right")
plt.ylabel("ATM IV (s_vol)")
plt.grid(True)
plt.show()

plt.plot(metrics["asof_ts"], metrics["skew_90_110"])
plt.title(f"{symbol} Skew proxy (IV@0.9S - IV@1.1S) | Expiry {chosen_expiry}")
plt.xticks(rotation=30, ha="right")
plt.ylabel("Skew proxy")
plt.grid(True)
plt.show()
</code></pre>
<p>Here is the first chart, ATM IV over time.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/0df9b0ff-e02f-4c6b-b4ec-175ddc46522c.png" alt="TSLA ATM IV over time" style="display:block;margin:0 auto" width="831" height="453" loading="lazy">

<p>ATM IV tends to move slowly over short windows unless there is a sharp repricing event. In this run, it stays fairly stable, which is a realistic outcome for a short capture. The value here is that the database turns "fairly stable" into something you can quantify and compare later, rather than a vague impression.</p>
<p>Here is the second chart, Skew proxy over time.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/f90243ee-6039-4d7e-94ed-d248eaaf9722.png" alt="TSLA skew proxy" style="display:block;margin:0 auto" width="831" height="453" loading="lazy">

<p>The skew proxy is more sensitive because it's based on wing points. If it changes, it usually means the downside is being repriced differently from the upside for that expiry. One nuance is that the nearest available strike can change between snapshots, which can create step-like moves even when the surface isn't moving dramatically. That's why we keep k90 and k110 in the metrics table. It keeps the skew plot explainable.</p>
<h2 id="heading-alert-style-thresholds">Alert-Style Thresholds</h2>
<p>Once you have a metrics table per snapshot, adding a monitoring layer is straightforward. The idea isn't to generate trades. It's to flag when the surface moves enough that someone should look closer.</p>
<p>Here we do two checks:</p>
<ul>
<li><p>ATM IV change alert: Flag if ATM IV changes more than a small threshold between snapshots.</p>
</li>
<li><p>Skew change alert: Flag if the skew proxy changes more than a threshold between snapshots.</p>
</li>
</ul>
<pre><code class="language-python">alerts = metrics.copy()

alerts["atm_iv_change"] = alerts["atm_iv"].diff()
alerts["skew_change"] = alerts["skew_90_110"].diff()

atm_thresh = 0.002    
skew_thresh = 0.003   

alerts["atm_alert"] = alerts["atm_iv_change"].abs() &gt;= atm_thresh
alerts["skew_alert"] = alerts["skew_change"].abs() &gt;= skew_thresh

alerts[[
    "asof_ts",
    "atm_iv", "atm_iv_change", "atm_alert",
    "skew_90_110", "skew_change", "skew_alert",
    "atm_strike", "k90", "k110"
]]
</code></pre>
<p>We take the per-snapshot metrics table and compute first differences. Then we compare those changes to thresholds and store boolean flags. The output table keeps both the metrics and the strikes used for the calculations, so any alert is explainable rather than a black box.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/b6805adc-90f6-4c57-8dee-aa6e0ec4d724.png" alt="Alerts dataframe" style="display:block;margin:0 auto" width="1500" height="546" loading="lazy">

<p>In this run, the ATM IV alerts are all false, while the skew alert triggers once.</p>
<p>The skew alert fires because the skew proxy jumps by more than the threshold between two snapshots. This is explainable. If you see the table, you can see the strikes used for the proxy changed around the same time (k90 shifts from 340 to 315). Because strikes are discrete, nearest-strike metrics can step even when the surface is not moving dramatically.</p>
<p>To make this easier to read, we also plot the two series and mark alert points.</p>
<pre><code class="language-python">plt.plot(alerts["asof_ts"], alerts["atm_iv"])
for i, r in alerts[alerts["atm_alert"]].iterrows():
    plt.scatter(r["asof_ts"], r["atm_iv"],  s=30, edgecolors="r", alpha=0.6, linewidth=2)
plt.title(f"{symbol} ATM IV with alerts | Expiry {chosen_expiry}")
plt.xticks(rotation=30, ha="right")
plt.grid(True)
plt.show()

plt.plot(alerts["asof_ts"], alerts["skew_90_110"])
for i, r in alerts[alerts["skew_alert"]].iterrows():
    plt.scatter(r["asof_ts"], r["skew_90_110"], s=30, edgecolors="r", alpha=0.6, linewidth=2)
plt.title(f"{symbol} Skew proxy with alerts | Expiry {chosen_expiry}")
plt.xticks(rotation=30, ha="right")
plt.grid(True)
plt.show()
</code></pre>
<p>Both plots use the same pattern. Plot the metric as a line, then overlay a marker on any timestamp where the corresponding alert flag is true. This makes it obvious when something crossed the threshold.</p>
<p>This chart represents skew proxy with alerts.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/eff87263-68f0-4132-935d-bdf148e73c82.png" alt="TSLA skew proxy with alerts" style="display:block;margin:0 auto" width="831" height="453" loading="lazy">

<p>This chart shows one alert marker, which matches what we saw in the table.</p>
<p>The ATM IV plot isn't featured since there are no alert points.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>In this walkthrough, we used SpiderRock MLink's LiveImpliedQuote feed for TSLA and turned it into a small internal database you can query. We stored every snapshot in an append-only history table, maintained a latest view keyed by a stable option identifier, then used that stored data to rebuild a smile, track ATM surface IV and a simple skew proxy, and add a basic alert rule on top.</p>
<p>This fits well in B2B workflows because it turns live analytics into something operational: a dataset you can audit, replay, and monitor. The same pattern works whether you're building an internal dashboard, running routine surface checks for a desk, or doing a quick post-event review without relying on screenshots and one-off notebook runs.</p>
<p>If you want to extend it, the most practical next steps are longer capture windows, tracking multiple symbols, and moving from SQLite to Postgres once the data volume grows. If metric stability becomes important, you can also standardize the slice you track per poll or interpolate IV to fixed moneyness points so skew measures don't step when nearest strikes change.</p>
<p>With that being said, you've reached the end of the article. Hope you learned something new and useful.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Use PostgreSQL as a Cache, Queue, and Search Engine ]]>
                </title>
                <description>
                    <![CDATA[ "Just use Postgres" has been circulating as advice for years, but most articles arguing for it are opinion pieces. I wanted hard numbers. So I built a benchmark suite that pits vanilla PostgreSQL agai ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-use-postgresql-as-a-cache-queue-and-search-engine/</link>
                <guid isPermaLink="false">69e7accfe43672781470ff97</guid>
                
                    <category>
                        <![CDATA[ PostgreSQL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ database ]]>
                    </category>
                
                    <category>
                        <![CDATA[ backend ]]>
                    </category>
                
                    <category>
                        <![CDATA[ performance ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Databases ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Aaron Yong ]]>
                </dc:creator>
                <pubDate>Tue, 21 Apr 2026 16:58:55 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/6fcdd3c0-eead-42a7-b2f0-cf4c6a3d06dc.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>"Just use Postgres" has been circulating as advice for years, but most articles arguing for it are opinion pieces. I wanted hard numbers.</p>
<p>So I built a benchmark suite that pits vanilla PostgreSQL against a feature-optimized PostgreSQL instance — measuring caching, message queues, full-text search, and pub/sub under controlled conditions.</p>
<p>In this article, you'll learn how to use PostgreSQL's built-in features for caching, job queues, full-text search, and pub/sub. You'll see actual benchmark results (latency percentiles, throughput, and error rates) comparing naive PostgreSQL patterns against optimized ones, and understand where PostgreSQL's limits are so you can decide whether you really need that extra service in your stack.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-setup">The Setup</a></p>
</li>
<li><p><a href="#heading-benchmark-1-caching-with-unlogged-tables">Benchmark 1: Caching with UNLOGGED Tables</a></p>
</li>
<li><p><a href="#heading-benchmark-2-job-queues-with-skip-locked">Benchmark 2: Job Queues with SKIP LOCKED</a></p>
</li>
<li><p><a href="#heading-benchmark-3-full-text-search-with-tsvector">Benchmark 3: Full-Text Search with tsvector</a></p>
</li>
<li><p><a href="#heading-benchmark-4-pubsub-with-listennotify">Benchmark 4: Pub/Sub with LISTEN/NOTIFY</a></p>
</li>
<li><p><a href="#heading-the-combined-workload-the-honest-test">The Combined Workload: The Honest Test</a></p>
</li>
<li><p><a href="#heading-what-i-learned">What I Learned</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along or reproduce the benchmarks, you'll need:</p>
<ul>
<li><p>Docker and Docker Compose</p>
</li>
<li><p>Node.js 20+ (for the Express TypeScript API layer)</p>
</li>
<li><p><a href="https://k6.io/">k6</a> for load testing</p>
</li>
<li><p>Basic familiarity with SQL and PostgreSQL</p>
</li>
</ul>
<p>The full benchmark project is <a href="https://github.com/aaronhsyong2/pg-stack-benchmark">open source on GitHub</a> — you can clone it and run every test yourself.</p>
<h2 id="heading-the-setup">The Setup</h2>
<p>The benchmark uses two identical PostgreSQL 17 instances running in Docker containers, each with fixed resource constraints (2 CPUs, 2 GB RAM). Both share the same Express TypeScript API layer — the only difference is which PostgreSQL features are enabled.</p>
<pre><code class="language-plaintext">┌─────────┐     ┌──────────────────┐     ┌─────────────────┐
│   k6    │────&gt;│  Express API     │────&gt;│  PG Baseline    │
│  (load  │     │  (TypeScript)    │     │  (vanilla PG17) │
│  test)  │────&gt;│  Port 3001/3002  │────&gt;│  PG Modded      │
└─────────┘     └──────────────────┘     │  (features on)  │
                                         └─────────────────┘
</code></pre>
<p>The baseline instance uses naïve approaches (regular tables, <code>ILIKE</code> search, polling). The modded instance uses PostgreSQL's built-in features (UNLOGGED tables, <code>tsvector</code> with GIN indexes, <code>LISTEN/NOTIFY</code>, partial indexes). Same hardware, same API code, same data. Only the database features differ.</p>
<p>Both instances share this tuned <code>postgresql.conf</code>:</p>
<pre><code class="language-ini"># Memory allocation
shared_buffers = 512MB           # 25% of available RAM
effective_cache_size = 1536MB    # 75% of RAM — helps the query planner
work_mem = 16MB                  # per-sort/hash operation memory

# SSD-optimized planner settings
random_page_cost = 1.1           # default 4.0 assumes spinning disks
effective_io_concurrency = 200   # allow parallel I/O on SSDs
</code></pre>
<p>These settings matter. The defaults assume spinning disks from the early 2000s. Setting <code>random_page_cost = 1.1</code> tells the query planner that random reads are nearly as fast as sequential reads on SSDs, which encourages index usage over sequential scans.</p>
<h2 id="heading-benchmark-1-caching-with-unlogged-tables">Benchmark 1: Caching with UNLOGGED Tables</h2>
<p><strong>The idea:</strong> Use an UNLOGGED table as an in-database cache. UNLOGGED tables skip PostgreSQL's Write-Ahead Log (WAL) — the mechanism that guarantees durability. Since cache data is ephemeral by nature, losing it on a crash is acceptable, and skipping WAL removes the biggest write bottleneck.</p>
<pre><code class="language-sql">-- Modded: UNLOGGED table for cache entries
CREATE UNLOGGED TABLE cache_entries (
    key TEXT PRIMARY KEY,
    value JSONB NOT NULL,
    expires_at TIMESTAMPTZ
);

-- Baseline: same schema, but a regular (logged) table
CREATE TABLE cache_entries (
    key TEXT PRIMARY KEY,
    value JSONB NOT NULL,
    expires_at TIMESTAMPTZ
);
</code></pre>
<h3 id="heading-results-200-virtual-users">Results (200 Virtual Users)</h3>
<table>
<thead>
<tr>
<th>Mode</th>
<th>p50</th>
<th>p95</th>
<th>avg</th>
<th>req/s</th>
</tr>
</thead>
<tbody><tr>
<td>Baseline (regular table)</td>
<td>1.87ms</td>
<td>6.00ms</td>
<td>2.50ms</td>
<td>1,754/s</td>
</tr>
<tr>
<td>Modded (UNLOGGED table)</td>
<td>1.71ms</td>
<td>5.24ms</td>
<td>2.17ms</td>
<td>1,760/s</td>
</tr>
</tbody></table>
<p>A consistent 13% improvement across all percentiles. Not dramatic, but free — you change one keyword in your <code>CREATE TABLE</code> statement.</p>
<h3 id="heading-under-stress-1000-virtual-users-no-sleep">Under Stress (1,000 Virtual Users, No Sleep)</h3>
<table>
<thead>
<tr>
<th>Mode</th>
<th>p50</th>
<th>p95</th>
<th>req/s</th>
<th>Total Requests</th>
</tr>
</thead>
<tbody><tr>
<td>Baseline</td>
<td>83.38ms</td>
<td>143.23ms</td>
<td>7,663/s</td>
<td>728,021</td>
</tr>
<tr>
<td>Modded</td>
<td>77.69ms</td>
<td>126.39ms</td>
<td>8,062/s</td>
<td>765,934</td>
</tr>
</tbody></table>
<p>The relative improvement stays locked at 12-13% regardless of load level. The UNLOGGED advantage is a per-write optimization — it saves the same amount of I/O whether you are doing 100 or 10,000 writes per second. The modded instance served 37,000 more requests in the same time window.</p>
<h3 id="heading-the-verdict">The Verdict</h3>
<p>UNLOGGED tables won't match Redis for sub-millisecond hot-path caching (real-time bidding, gaming leaderboards). But for web applications where the difference between 2ms and 5ms is invisible to users, they eliminate an entire infrastructure dependency for zero additional complexity.</p>
<p>You do give up Redis data structures (sorted sets, HyperLogLog, streams). If you need those, a dedicated cache is still the right call.</p>
<h2 id="heading-benchmark-2-job-queues-with-skip-locked">Benchmark 2: Job Queues with SKIP LOCKED</h2>
<p><strong>The idea:</strong> Use PostgreSQL as a job queue with <code>SELECT ... FOR UPDATE SKIP LOCKED</code>. Multiple workers poll the same table, and <code>SKIP LOCKED</code> ensures each worker gets a different row — no duplicates, no contention.</p>
<pre><code class="language-sql">-- Queue table with a partial index on pending jobs only
CREATE TABLE job_queue (
    id SERIAL PRIMARY KEY,
    payload JSONB NOT NULL,
    status TEXT NOT NULL DEFAULT 'pending',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Partial index: only indexes pending jobs
-- As jobs complete, they leave the index — it stays small forever
CREATE INDEX idx_pending_jobs ON job_queue (created_at)
    WHERE status = 'pending';
</code></pre>
<p>The dequeue pattern:</p>
<pre><code class="language-sql">-- Atomic dequeue: select + update in one statement
UPDATE job_queue SET status = 'processing'
WHERE id = (
    SELECT id FROM job_queue
    WHERE status = 'pending'
    ORDER BY created_at
    LIMIT 1
    FOR UPDATE SKIP LOCKED  -- skip rows locked by other workers
) RETURNING *;
</code></pre>
<p>How <code>SKIP LOCKED</code> works: Worker A locks row 1. Worker B tries row 1, sees the lock, skips it, and takes row 2 instead. No blocking, no duplicates. If a worker crashes, the transaction rolls back and the row becomes available again.</p>
<h3 id="heading-results-100-producers-50-consumers">Results (100 Producers + 50 Consumers)</h3>
<table>
<thead>
<tr>
<th>Mode</th>
<th>p50</th>
<th>p95</th>
<th>avg</th>
<th>req/s</th>
</tr>
</thead>
<tbody><tr>
<td>Baseline (full index)</td>
<td>1.90ms</td>
<td>5.01ms</td>
<td>2.30ms</td>
<td>1,053/s</td>
</tr>
<tr>
<td>Modded (partial index)</td>
<td>1.81ms</td>
<td>5.28ms</td>
<td>2.29ms</td>
<td>1,052/s</td>
</tr>
</tbody></table>
<p>They're virtually identical. The partial index doesn't show its value in a 60-second benchmark because the table doesn't accumulate enough completed rows for the index size difference to matter. In a production system with millions of completed jobs, the partial index keeps the index at kilobytes while a full index grows to gigabytes.</p>
<h3 id="heading-the-verdict">The Verdict</h3>
<p><code>SKIP LOCKED</code> is production-ready for job queues. Libraries like <a href="https://github.com/timgit/pg-boss">pg-boss</a> (Node.js) and <a href="https://github.com/riverqueue/river">river</a> (Go) build on this exact pattern.</p>
<p>You do give up exchange/routing patterns (fan-out, topic-based routing) and consumer groups with message replay. If you need those, a dedicated message broker is still the right tool. For simple "process this job once" workloads, PostgreSQL handles it.</p>
<h2 id="heading-benchmark-3-full-text-search-with-tsvector">Benchmark 3: Full-Text Search with tsvector</h2>
<p><strong>The idea:</strong> Use PostgreSQL's built-in full-text search instead of a separate search service. A <code>tsvector</code> column stores pre-processed search tokens, and a GIN (Generalized Inverted Index) enables fast lookups using the same inverted index concept that powers Elasticsearch.</p>
<pre><code class="language-sql">-- Search-optimized article table
CREATE TABLE articles (
    id SERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    body TEXT NOT NULL,
    search_vector tsvector  -- pre-computed search tokens
);

-- GIN index for full-text search
CREATE INDEX idx_search ON articles USING GIN (search_vector);

-- Auto-update search_vector on insert/update
CREATE OR REPLACE FUNCTION update_search_vector() RETURNS trigger AS $$
BEGIN
    NEW.search_vector := to_tsvector('english',
        COALESCE(NEW.title, '') || ' ' || COALESCE(NEW.body, ''));
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_search
    BEFORE INSERT OR UPDATE ON articles
    FOR EACH ROW EXECUTE FUNCTION update_search_vector();
</code></pre>
<p>The baseline uses <code>ILIKE</code> with a leading wildcard — the approach most developers reach for first:</p>
<pre><code class="language-sql">-- Baseline: sequential scan on every query
SELECT * FROM articles
WHERE title ILIKE '%postgresql%' OR body ILIKE '%postgresql%';

-- Modded: GIN index lookup with relevance ranking
SELECT id, title,
    ts_rank(search_vector, plainto_tsquery('english', 'postgresql')) AS rank
FROM articles
WHERE search_vector @@ plainto_tsquery('english', 'postgresql')
ORDER BY rank DESC LIMIT 20;
</code></pre>
<h3 id="heading-results-500-virtual-users">Results (500 Virtual Users)</h3>
<table>
<thead>
<tr>
<th>Mode</th>
<th>p50</th>
<th>p95</th>
<th>avg</th>
<th>req/s</th>
</tr>
</thead>
<tbody><tr>
<td>Baseline (ILIKE)</td>
<td>1.96ms</td>
<td>101.83ms</td>
<td>25.22ms</td>
<td>561/s</td>
</tr>
<tr>
<td>Modded (tsvector + GIN)</td>
<td>2.76ms</td>
<td>10.39ms</td>
<td>3.76ms</td>
<td>675/s</td>
</tr>
</tbody></table>
<p>This is the standout result. The baseline's p95 of 101ms versus the modded's 10ms is a 10x improvement.</p>
<p>Why the baseline's p50 (1.96ms) is slightly better than the modded's (2.76ms): simple <code>ILIKE</code> queries on small result sets can be fast when the data fits in <code>shared_buffers</code>. But as load increases and the buffer cache is contested, sequential scans degrade dramatically. The GIN index stays stable.</p>
<h3 id="heading-under-stress-500-virtual-users-no-sleep">Under Stress (500 Virtual Users, No Sleep)</h3>
<table>
<thead>
<tr>
<th>Mode</th>
<th>p50</th>
<th>p95</th>
<th>req/s</th>
<th>Total Requests</th>
</tr>
</thead>
<tbody><tr>
<td>Baseline (ILIKE)</td>
<td>599ms</td>
<td>1,000ms</td>
<td>558/s</td>
<td>50,212</td>
</tr>
<tr>
<td>Modded (tsvector)</td>
<td>209ms</td>
<td>396ms</td>
<td>1,441/s</td>
<td>129,679</td>
</tr>
</tbody></table>
<p>ILIKE collapses to 1-second p95 latencies. Each query forces a sequential scan of all 10,000 articles, blocking shared buffers and starving concurrent queries. The tsvector approach serves 2.6x more requests in the same time window because the GIN index lookup is O(log n) regardless of concurrency.</p>
<h3 id="heading-the-verdict">The Verdict</h3>
<p>This is the strongest argument in the entire benchmark. The fix requires zero extensions — <code>to_tsvector()</code>, <code>plainto_tsquery()</code>, and <code>CREATE INDEX USING GIN</code> are all built into core PostgreSQL. If you're doing <code>WHERE column ILIKE '%term%'</code> on any table with more than a few thousand rows, you're leaving massive performance on the table.</p>
<p>You do give up distributed search across shards, complex analyzers for CJK languages, and aggregation/faceted search pipelines. For a product search bar, blog search, or internal tool — PostgreSQL is enough.</p>
<h2 id="heading-benchmark-4-pubsub-with-listennotify">Benchmark 4: Pub/Sub with LISTEN/NOTIFY</h2>
<p><strong>The idea:</strong> Use PostgreSQL's native <code>LISTEN/NOTIFY</code> for pub/sub messaging, triggered automatically on INSERT via a database trigger.</p>
<pre><code class="language-sql">-- Trigger that fires pg_notify on every new message
CREATE OR REPLACE FUNCTION notify_message() RETURNS trigger AS $$
BEGIN
    PERFORM pg_notify(NEW.channel, NEW.payload::text);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_notify
    AFTER INSERT ON messages
    FOR EACH ROW EXECUTE FUNCTION notify_message();
</code></pre>
<h3 id="heading-results-200-virtual-users">Results (200 Virtual Users)</h3>
<table>
<thead>
<tr>
<th>Mode</th>
<th>p50</th>
<th>p95</th>
<th>avg</th>
<th>req/s</th>
</tr>
</thead>
<tbody><tr>
<td>Baseline (poll-based)</td>
<td>1.99ms</td>
<td>6.04ms</td>
<td>2.84ms</td>
<td>1,116/s</td>
</tr>
<tr>
<td>Modded (LISTEN/NOTIFY)</td>
<td>1.65ms</td>
<td>4.80ms</td>
<td>2.13ms</td>
<td>1,131/s</td>
</tr>
</tbody></table>
<p>Here we have a 20% improvement at p95. The trigger-based approach does more work per INSERT (INSERT + NOTIFY), but the reduced round trips and better connection reuse patterns offset the overhead.</p>
<h3 id="heading-the-verdict">The Verdict</h3>
<p><code>LISTEN/NOTIFY</code> works for real-time features where you would otherwise reach for Redis pub/sub. The main limitation is payload size (8,000 bytes maximum) and the requirement for dedicated connections (incompatible with PgBouncer in transaction mode).</p>
<h2 id="heading-the-combined-workload-the-honest-test">The Combined Workload: The Honest Test</h2>
<p>Individual benchmarks are flattering. The real question: can one PostgreSQL instance handle caching, queues, search, and pub/sub simultaneously without degrading?</p>
<h3 id="heading-results-all-four-workloads-running-together">Results (All Four Workloads Running Together)</h3>
<table>
<thead>
<tr>
<th>Mode</th>
<th>p50</th>
<th>p95</th>
<th>avg</th>
<th>req/s</th>
</tr>
</thead>
<tbody><tr>
<td>Baseline</td>
<td>1.65ms</td>
<td>5.24ms</td>
<td>2.17ms</td>
<td>1,424/s</td>
</tr>
<tr>
<td>Modded</td>
<td>1.86ms</td>
<td>6.05ms</td>
<td>2.47ms</td>
<td>1,417/s</td>
</tr>
</tbody></table>
<p>Under combined load, the baseline marginally outperforms the modded setup. The modded PostgreSQL does more work per operation — maintaining GIN indexes, firing triggers, running <code>pg_cron</code> in the background. When all these features are active simultaneously, the overhead is measurable: about 15% higher p95 latency.</p>
<p>But both setups stay comfortably under 10ms at p95. For most web applications, that's more than good enough.</p>
<h2 id="heading-what-i-learned">What I Learned</h2>
<p>After running all these benchmarks, here's what I would tell a team evaluating whether to "just use Postgres":</p>
<ol>
<li><p><strong>Do it for full-text search:</strong> Switching from <code>ILIKE</code> to <code>tsvector</code> with a GIN index is a 10x improvement that requires zero extensions. This is the single highest-ROI change in the entire PostgreSQL ecosystem, and most developers don't know it exists.</p>
</li>
<li><p><strong>Do it for job queues:</strong> <code>SKIP LOCKED</code> is production-ready and eliminates RabbitMQ for simple "process this job" workloads. Use a library like pg-boss or river rather than rolling your own.</p>
</li>
<li><p><strong>Consider it for caching:</strong> UNLOGGED tables give a steady 13% improvement over regular tables. If sub-millisecond latency is not a hard requirement (and for most web apps, it is not), you can drop Redis entirely.</p>
</li>
<li><p><strong>Be honest about the overhead:</strong> Running all four roles simultaneously adds about 15% latency compared to running any single role. Whether that matters depends on your latency budget.</p>
</li>
<li><p><strong>Know where to stop:</strong> PostgreSQL won't match Redis for sub-millisecond caching, Kafka for millions of messages per second, or Elasticsearch for distributed multi-node search with complex analyzers. The line is at extreme throughput or extreme specialization.</p>
</li>
</ol>
<p>The honest conclusion is not "PostgreSQL does everything." It is: for most applications, a single well-configured PostgreSQL instance handles 80% of what you would otherwise need three to five additional services for. That is less infrastructure to deploy, monitor, and maintain — and fewer things to break at 3 AM.</p>
<p>Enterprise-scale applications processing millions of messages per second, serving sub-millisecond cache hits to millions of concurrent users, or running distributed search across terabytes of documents will still need specialized tools. Those tools exist for a reason, and at that scale the operational cost of running them is justified by the performance you get back.</p>
<p>But most of us aren't building at that scale — and may never need to. Starting with PostgreSQL for these roles means you ship faster with fewer moving parts. If and when you outgrow what PostgreSQL can handle, your benchmarks will tell you exactly which role needs to be extracted into a dedicated service. That is a much better position than starting with five services on day one because you assumed you would need them.</p>
<p>The <a href="https://github.com/aaronhsyong2/pg-stack-benchmark">benchmark project</a> is open source if you want to reproduce these results or adapt the tests for your own workload.</p>
<p>You can find more of my writing at <a href="https://site.aaronhsyong.com">site.aaronhsyong.com</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How Database Indexes Work – A Practical Guide with PostgreSQL Examples ]]>
                </title>
                <description>
                    <![CDATA[ Every developer eventually runs into a slow query. The table has grown from a few hundred rows to a few million, and what used to take milliseconds now takes seconds — or worse. The fix, more often th ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-database-indexes-work-a-practical-guide-with-postgresql-examples/</link>
                <guid isPermaLink="false">69e11c10ffbb787634dea035</guid>
                
                    <category>
                        <![CDATA[ Databases ]]>
                    </category>
                
                    <category>
                        <![CDATA[ PostgreSQL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ indexing ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ iyiola ]]>
                </dc:creator>
                <pubDate>Thu, 16 Apr 2026 17:27:44 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/cf6919a4-f803-4783-83ff-5c7674141c55.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every developer eventually runs into a slow query. The table has grown from a few hundred rows to a few million, and what used to take milliseconds now takes seconds — or worse.</p>
<p>The fix, more often than not, is an index.</p>
<p>A database index is a data structure that helps the database find rows faster without scanning the entire table. It works a lot like the index at the back of a textbook: instead of reading every page to find a topic, you look it up in the index, get the page number, and go straight there.</p>
<p>In this tutorial, you'll learn how indexes work under the hood, how to create and use them effectively in PostgreSQL, and how to avoid the common mistakes that make indexes useless or even harmful.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-why-do-you-need-indexes">Why Do You Need Indexes?</a></p>
</li>
<li><p><a href="#heading-how-indexes-work-under-the-hood">How Indexes Work Under the Hood</a></p>
</li>
<li><p><a href="#heading-how-to-create-your-first-index">How to Create Your First Index</a></p>
</li>
<li><p><a href="#heading-how-to-use-explain-analyze-to-measure-performance">How to Use EXPLAIN ANALYZE to Measure Performance</a></p>
</li>
<li><p><a href="#heading-types-of-indexes-in-postgresql">Types of Indexes in PostgreSQL</a></p>
</li>
<li><p><a href="#heading-how-to-create-a-composite-index">How to Create a Composite Index</a></p>
</li>
<li><p><a href="#heading-how-to-create-a-partial-index">How to Create a Partial Index</a></p>
</li>
<li><p><a href="#heading-how-to-create-an-expression-index">How to Create an Expression Index</a></p>
</li>
<li><p><a href="#heading-how-to-create-a-unique-index">How to Create a Unique Index</a></p>
</li>
<li><p><a href="#heading-how-to-manage-indexes">How to Manage Indexes</a></p>
</li>
<li><p><a href="#heading-when-indexes-hurt-instead-of-help">When Indexes Hurt Instead of Help</a></p>
</li>
<li><p><a href="#heading-common-mistakes-that-prevent-index-usage">Common Mistakes That Prevent Index Usage</a></p>
</li>
<li><p><a href="#heading-best-practices-for-indexing">Best Practices for Indexing</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along with the examples, you'll need:</p>
<ul>
<li><p>Basic knowledge of SQL (SELECT, INSERT, UPDATE, DELETE, WHERE, JOIN)</p>
</li>
<li><p>A running PostgreSQL instance (version 12 or later)</p>
</li>
<li><p>A SQL client like <code>psql</code>, pgAdmin, or DBeaver</p>
</li>
</ul>
<p>If you don't have PostgreSQL installed locally, you can use a free cloud-hosted instance from services like <a href="https://neon.tech">Neon</a> or <a href="https://supabase.com">Supabase</a>.</p>
<h2 id="heading-why-do-you-need-indexes">Why Do You Need Indexes?</h2>
<p>When you run a query like <code>SELECT * FROM users WHERE email = 'jane@example.com'</code>, the database needs to find the matching row. Without an index, PostgreSQL performs a <strong>sequential scan</strong> — it reads every single row in the table and checks whether the <code>email</code> column matches.</p>
<p>For a table with 100 rows, this is fine. For a table with 10 million rows, it's painfully slow.</p>
<p>An index solves this by creating a separate, sorted data structure that maps column values to their row locations. Instead of scanning 10 million rows, PostgreSQL can look up the value in the index and jump directly to the matching row. This can reduce query time from seconds to milliseconds.</p>
<p>But indexes aren't free. They come with trade-offs you need to understand before adding them everywhere. You'll learn about those trade-offs throughout this tutorial.</p>
<h2 id="heading-how-indexes-work-under-the-hood">How Indexes Work Under the Hood</h2>
<p>PostgreSQL's default index type is the <strong>B-tree</strong> (balanced tree). Understanding how a B-tree works will help you make smarter decisions about when and how to index.</p>
<p>A B-tree organizes data into a sorted, hierarchical structure with three levels:</p>
<ol>
<li><p><strong>Root node</strong> — the top of the tree. It holds a few values that divide the data into broad ranges.</p>
</li>
<li><p><strong>Internal nodes</strong> — each one further narrows down the range.</p>
</li>
<li><p><strong>Leaf nodes</strong> — the bottom level. These hold the actual indexed values along with pointers to the corresponding rows in the table.</p>
</li>
</ol>
<p>When PostgreSQL uses a B-tree index to find a value, it starts at the root and follows the path that matches the target value, moving through internal nodes until it reaches the correct leaf node. This path is called a <strong>tree traversal</strong>, and it typically requires only 3–4 steps even for tables with millions of rows.</p>
<p>Think of it like a phone book. You don't start at page one and read every name. You open to roughly the right section (root), narrow it down to the right page (internal nodes), and scan the entries on that page (leaf node).</p>
<p>This sorted structure is also why B-tree indexes work well for range queries like <code>WHERE price &gt; 50 AND price &lt; 100</code>. The database finds the starting point in the tree and then scans forward through the leaf nodes, which are already in order.</p>
<h2 id="heading-how-to-create-your-first-index">How to Create Your First Index</h2>
<p>Let's build a practical example. You'll create a table, load it with data, and see the difference an index makes.</p>
<h3 id="heading-step-1-create-the-table-and-insert-sample-data">Step 1 – Create the Table and Insert Sample Data</h3>
<pre><code class="language-sql">CREATE TABLE customers (
    id SERIAL PRIMARY KEY,
    first_name VARCHAR(50) NOT NULL,
    last_name VARCHAR(50) NOT NULL,
    email VARCHAR(100) NOT NULL,
    city VARCHAR(50),
    created_at TIMESTAMP DEFAULT NOW()
);
</code></pre>
<p>Now insert a large number of rows so the performance difference is visible. This generates 500,000 rows of sample data:</p>
<pre><code class="language-sql">INSERT INTO customers (first_name, last_name, email, city)
SELECT
    'User' || gs,
    'Last' || gs,
    'user' || gs || '@example.com',
    (ARRAY['Lagos', 'London', 'New York', 'Berlin', 'Tokyo'])[1 + (gs % 5)]
FROM generate_series(1, 500000) AS gs;
</code></pre>
<h3 id="heading-step-2-query-without-an-index">Step 2 – Query Without an Index</h3>
<pre><code class="language-sql">EXPLAIN ANALYZE
SELECT * FROM customers WHERE email = 'user250000@example.com';
</code></pre>
<p>You'll see output similar to this:</p>
<pre><code class="language-plaintext">Seq Scan on customers  (cost=0.00..11374.00 rows=1 width=52) (actual time=45.123..91.456 rows=1 loops=1)
  Filter: ((email)::text = 'user250000@example.com'::text)
  Rows Removed by Filter: 499999
Planning Time: 0.085 ms
Execution Time: 91.502 ms
</code></pre>
<p>The key detail here is <code>Seq Scan</code> — PostgreSQL scanned all 500,000 rows to find a single match. It filtered out 499,999 rows. That's a lot of wasted work.</p>
<h3 id="heading-step-3-create-an-index">Step 3 – Create an Index</h3>
<pre><code class="language-sql">CREATE INDEX idx_customers_email ON customers (email);
</code></pre>
<p>This creates a B-tree index on the <code>email</code> column. The name <code>idx_customers_email</code> follows a common naming convention: <code>idx_</code> prefix, then the table name, then the column name.</p>
<h3 id="heading-step-4-query-with-the-index">Step 4 – Query With the Index</h3>
<p>Run the same query again:</p>
<pre><code class="language-sql">EXPLAIN ANALYZE
SELECT * FROM customers WHERE email = 'user250000@example.com';
</code></pre>
<p>Now you'll see something like this:</p>
<pre><code class="language-plaintext">Index Scan using idx_customers_email on customers  (cost=0.42..8.44 rows=1 width=52) (actual time=0.034..0.036 rows=1 loops=1)
  Index Cond: ((email)::text = 'user250000@example.com'::text)
Planning Time: 0.112 ms
Execution Time: 0.058 ms
</code></pre>
<p>The scan type changed from <code>Seq Scan</code> to <code>Index Scan</code>. The execution time dropped from ~91ms to ~0.06ms. That's roughly a 1,500x improvement — from one line of SQL.</p>
<h2 id="heading-how-to-use-explain-analyze-to-measure-performance">How to Use <code>EXPLAIN ANALYZE</code> to Measure Performance</h2>
<p><code>EXPLAIN ANALYZE</code> is your most important tool for understanding how PostgreSQL executes a query. You already saw it in the previous section, but let's break down what the output means.</p>
<pre><code class="language-sql">EXPLAIN ANALYZE SELECT * FROM customers WHERE city = 'Lagos';
</code></pre>
<p>The output will tell you several things:</p>
<ul>
<li><p><strong>Scan type</strong> — whether PostgreSQL used a sequential scan, index scan, bitmap index scan, or another access method</p>
</li>
<li><p><strong>Cost</strong> — the estimated cost in arbitrary units. The first number is the startup cost, the second is the total cost</p>
</li>
<li><p><strong>Rows</strong> — how many rows PostgreSQL estimated it would find versus how many it actually found</p>
</li>
<li><p><strong>Actual time</strong> — the real time in milliseconds to execute the query</p>
</li>
<li><p><strong>Rows Removed by Filter</strong> — how many rows were scanned but didn't match the condition</p>
</li>
</ul>
<p>If you see <code>Seq Scan</code> on a large table with a selective WHERE clause, that's usually a sign you need an index. If you see <code>Index Scan</code> or <code>Index Only Scan</code>, your index is working.</p>
<p>One thing to keep in mind: <code>EXPLAIN</code> without <code>ANALYZE</code> shows the plan without actually running the query. <code>EXPLAIN ANALYZE</code> runs the query and shows real timing data. Always use <code>EXPLAIN ANALYZE</code> when you're investigating performance, but be careful with it on destructive queries — <code>EXPLAIN ANALYZE DELETE FROM ...</code> will actually delete the rows. Wrap those in a transaction and roll back:</p>
<pre><code class="language-sql">BEGIN;
EXPLAIN ANALYZE DELETE FROM customers WHERE city = 'Berlin';
ROLLBACK;
</code></pre>
<h2 id="heading-types-of-indexes-in-postgresql">Types of Indexes in PostgreSQL</h2>
<p>PostgreSQL supports several index types, each optimized for different query patterns.</p>
<h3 id="heading-b-tree-default">B-tree (Default)</h3>
<p>B-tree is the default index type and covers the vast majority of use cases. It supports equality checks (<code>=</code>), range queries (<code>&lt;</code>, <code>&gt;</code>, <code>&lt;=</code>, <code>&gt;=</code>, <code>BETWEEN</code>), sorting (<code>ORDER BY</code>), and <code>IS NULL</code> / <code>IS NOT NULL</code> checks.</p>
<pre><code class="language-sql">-- These are equivalent – B-tree is the default
CREATE INDEX idx_name ON customers (last_name);
CREATE INDEX idx_name ON customers USING btree (last_name);
</code></pre>
<p>Use B-tree when you don't have a specific reason to use something else.</p>
<h3 id="heading-hash">Hash</h3>
<p>Hash indexes are optimized purely for equality comparisons (<code>=</code>). They don't support range queries or sorting. In practice, B-tree handles equality checks almost as fast, so hash indexes are rarely necessary.</p>
<pre><code class="language-sql">CREATE INDEX idx_email_hash ON customers USING hash (email);
</code></pre>
<p>Consider a hash index only if you have a very large table with frequent equality-only lookups and want to save a small amount of index space.</p>
<h3 id="heading-gin-generalized-inverted-index">GIN (Generalized Inverted Index)</h3>
<p>GIN indexes are designed for values that contain multiple elements — like arrays, JSONB documents, or full-text search vectors. Instead of indexing a single value per row, GIN indexes every element within the value.</p>
<pre><code class="language-sql">-- Add a JSONB column
ALTER TABLE customers ADD COLUMN preferences JSONB DEFAULT '{}';

-- Index the JSONB column
CREATE INDEX idx_preferences ON customers USING gin (preferences);

-- Now this query uses the GIN index
SELECT * FROM customers WHERE preferences @&gt; '{"newsletter": true}';
</code></pre>
<p>Use GIN when you're querying inside JSONB data, searching arrays with <code>@&gt;</code> or <code>&amp;&amp;</code>, or doing full-text search with <code>tsvector</code>.</p>
<h3 id="heading-gist-generalized-search-tree">GiST (Generalized Search Tree)</h3>
<p>GiST indexes support geometric data, ranges, and full-text search. They're commonly used with PostGIS for geospatial queries.</p>
<pre><code class="language-sql">-- Range type example
CREATE TABLE events (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100),
    duration TSRANGE
);

CREATE INDEX idx_event_duration ON events USING gist (duration);

-- Find overlapping events
SELECT * FROM events WHERE duration &amp;&amp; '[2025-01-01, 2025-01-31]'::tsrange;
</code></pre>
<p>Use GiST when you're working with spatial data, range types, or need overlap/containment operators.</p>
<h3 id="heading-brin-block-range-index">BRIN (Block Range Index)</h3>
<p>BRIN indexes are extremely small and work well on large tables where the physical row order correlates with the indexed column's value. A common example is a timestamp column on an append-only table where new rows always have later timestamps.</p>
<pre><code class="language-sql">CREATE INDEX idx_created_at_brin ON customers USING brin (created_at);
</code></pre>
<p>BRIN stores summary information (min/max values) for each block of rows rather than indexing every row individually. This makes the index much smaller than a B-tree, but it only works well when the data is naturally ordered.</p>
<p>Use BRIN for very large, append-only tables with naturally ordered data — like logs, events, or time-series data.</p>
<h2 id="heading-how-to-create-a-composite-index">How to Create a Composite Index</h2>
<p>A composite index (also called a multi-column index) covers more than one column. It's useful when your queries frequently filter or sort by multiple columns together.</p>
<pre><code class="language-sql">CREATE INDEX idx_city_lastname ON customers (city, last_name);
</code></pre>
<p>The order of columns in a composite index matters. PostgreSQL can use this index for queries that filter on <code>city</code> alone, or on both <code>city</code> and <code>last_name</code>. But it <strong>can't</strong> efficiently use this index for queries that filter only on <code>last_name</code>.</p>
<p>Think of it like a phone book sorted by city first, then by last name within each city. You can easily look up everyone in Lagos. You can also look up everyone named "Adeyemi" in Lagos. But finding all people named "Adeyemi" across all cities requires scanning the whole book.</p>
<p>This principle is called the <strong>leftmost prefix rule</strong>: PostgreSQL can use a composite index for queries that include the leftmost column(s) of the index, but not for queries that skip them.</p>
<pre><code class="language-sql">-- ✅ Uses the index (matches leftmost column)
SELECT * FROM customers WHERE city = 'Lagos';

-- ✅ Uses the index (matches both columns, left to right)
SELECT * FROM customers WHERE city = 'Lagos' AND last_name = 'Adeyemi';

-- ❌ Cannot use this index efficiently (skips the leftmost column)
SELECT * FROM customers WHERE last_name = 'Adeyemi';
</code></pre>
<p>When deciding column order, place the most selective column first — the one that narrows down the results the most.</p>
<h2 id="heading-how-to-create-a-partial-index">How to Create a Partial Index</h2>
<p>A partial index covers only a subset of rows in a table. You define the subset with a WHERE clause in the index definition.</p>
<p>This is useful when you only query a specific portion of the data. For example, if you have an <code>orders</code> table and you frequently query for pending orders but rarely look at completed ones:</p>
<pre><code class="language-sql">CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    customer_id INT NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'pending',
    total NUMERIC(10, 2),
    created_at TIMESTAMP DEFAULT NOW()
);

-- Only index rows where status is 'pending'
CREATE INDEX idx_orders_pending ON orders (customer_id)
WHERE status = 'pending';
</code></pre>
<p>This index is smaller than a full index because it skips all rows that don't match the WHERE condition. Smaller indexes use less disk space, consume less memory, and are faster to maintain during writes.</p>
<p>For the index to be used, your query's WHERE clause must match the index's condition:</p>
<pre><code class="language-sql">-- ✅ Uses the partial index
SELECT * FROM orders WHERE status = 'pending' AND customer_id = 42;

-- ❌ Cannot use the partial index (different status)
SELECT * FROM orders WHERE status = 'shipped' AND customer_id = 42;
</code></pre>
<h2 id="heading-how-to-create-an-expression-index">How to Create an Expression Index</h2>
<p>Sometimes you need to index the result of a function or expression rather than a raw column value. Expression indexes (also called functional indexes) handle this.</p>
<p>A common scenario is case-insensitive email lookups. If your queries use <code>LOWER(email)</code>, a regular index on <code>email</code> won't help — PostgreSQL sees the function call as a different expression.</p>
<pre><code class="language-sql">-- Regular index on email – won't help with LOWER() queries
CREATE INDEX idx_email ON customers (email);

-- This query does NOT use the index above
SELECT * FROM customers WHERE LOWER(email) = 'user100@example.com';
</code></pre>
<p>To fix this, create an index on the expression itself:</p>
<pre><code class="language-sql">CREATE INDEX idx_email_lower ON customers (LOWER(email));
</code></pre>
<p>Now queries that use <code>LOWER(email)</code> in their WHERE clause will use this index:</p>
<pre><code class="language-sql">-- ✅ Uses the expression index
SELECT * FROM customers WHERE LOWER(email) = 'user100@example.com';
</code></pre>
<p>The rule is straightforward: the expression in your query must match the expression in the index exactly. If the index is on <code>LOWER(email)</code>, your query must also use <code>LOWER(email)</code>.</p>
<h2 id="heading-how-to-create-a-unique-index">How to Create a Unique Index</h2>
<p>A unique index guarantees that no two rows have the same value (or combination of values) in the indexed columns. It serves a dual purpose: it enforces data integrity and provides fast lookups.</p>
<pre><code class="language-sql">CREATE UNIQUE INDEX idx_customers_email_unique ON customers (email);
</code></pre>
<p>If you try to insert a duplicate value, PostgreSQL will reject the operation:</p>
<pre><code class="language-sql">INSERT INTO customers (first_name, last_name, email, city)
VALUES ('Test', 'User', 'user1@example.com', 'Lagos');
-- ERROR: duplicate key value violates unique constraint "idx_customers_email_unique"
</code></pre>
<p>You might wonder how this differs from a UNIQUE constraint. Under the hood, PostgreSQL implements UNIQUE constraints by creating a unique index. The two are functionally identical.</p>
<p>The difference is intent — a UNIQUE constraint expresses a data integrity rule, while a unique index explicitly focuses on query performance with uniqueness as a bonus.</p>
<h2 id="heading-how-to-manage-indexes">How to Manage Indexes</h2>
<p>As your database grows, you'll need to inspect, monitor, and maintain your indexes.</p>
<h3 id="heading-how-to-list-all-indexes-on-a-table">How to List All Indexes on a Table</h3>
<pre><code class="language-sql">SELECT
    indexname,
    indexdef
FROM pg_indexes
WHERE tablename = 'customers';
</code></pre>
<p>This shows the name and full definition of every index on the table.</p>
<h3 id="heading-how-to-check-index-size">How to Check Index Size</h3>
<pre><code class="language-sql">SELECT
    pg_size_pretty(pg_relation_size('idx_customers_email')) AS index_size;
</code></pre>
<p>For a broader view of all indexes and their sizes:</p>
<pre><code class="language-sql">SELECT
    indexrelname AS index_name,
    pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE relname = 'customers'
ORDER BY pg_relation_size(indexrelid) DESC;
</code></pre>
<h3 id="heading-how-to-find-unused-indexes">How to Find Unused Indexes</h3>
<p>Indexes that are never used waste disk space and slow down writes. You can find them by checking <code>pg_stat_user_indexes</code>:</p>
<pre><code class="language-sql">SELECT
    indexrelname AS index_name,
    idx_scan AS times_used,
    pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE relname = 'customers'
AND idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;
</code></pre>
<p>If an index has <code>idx_scan = 0</code> after a reasonable period of normal usage, it's a candidate for removal. Just make sure to check across a full business cycle — some indexes are only used during monthly reports or seasonal operations.</p>
<h3 id="heading-how-to-drop-an-index">How to Drop an Index</h3>
<pre><code class="language-sql">DROP INDEX IF EXISTS idx_customers_email;
</code></pre>
<p>If you're dropping an index on a production table and want to avoid locking writes, use <code>CONCURRENTLY</code>:</p>
<pre><code class="language-sql">DROP INDEX CONCURRENTLY IF EXISTS idx_customers_email;
</code></pre>
<h3 id="heading-how-to-rebuild-an-index">How to Rebuild an Index</h3>
<p>Over time, indexes can become bloated as rows are inserted, updated, and deleted. You can rebuild an index to reclaim space:</p>
<pre><code class="language-sql">REINDEX INDEX idx_customers_email;
</code></pre>
<p>Or rebuild all indexes on a table:</p>
<pre><code class="language-sql">REINDEX TABLE customers;
</code></pre>
<p>On production systems, use <code>REINDEX CONCURRENTLY</code> (PostgreSQL 12+) to avoid locking the table:</p>
<pre><code class="language-sql">REINDEX INDEX CONCURRENTLY idx_customers_email;
</code></pre>
<h2 id="heading-when-indexes-hurt-instead-of-help">When Indexes Hurt Instead of Help</h2>
<p>Indexes aren't free. Every index you add comes with costs:</p>
<ol>
<li><p><strong>Write overhead</strong> — every INSERT, UPDATE, or DELETE must also update every index on the table. If a table has 10 indexes and you insert a row, PostgreSQL performs 11 write operations (one for the table and one for each index). On write-heavy tables, excessive indexes can significantly slow down data modification.</p>
</li>
<li><p><strong>Storage cost</strong> — indexes consume disk space. On large tables, indexes can take up as much space as the table itself, sometimes more. You can check this with <code>pg_relation_size</code>.</p>
</li>
<li><p><strong>Memory consumption</strong> — PostgreSQL caches frequently used indexes in memory. More indexes means more memory pressure, which can push useful data out of the cache and slow down other queries.</p>
</li>
<li><p><strong>Maintenance burden</strong> — indexes need periodic maintenance (vacuuming, reindexing) and add complexity to schema migrations.</p>
</li>
</ol>
<p>The question to ask is not "should I add an index?" but rather "does the read performance gain justify the write performance cost for this table's workload?"</p>
<h2 id="heading-common-mistakes-that-prevent-index-usage">Common Mistakes That Prevent Index Usage</h2>
<p>You can have the perfect index and PostgreSQL might still ignore it. Here are the most common reasons.</p>
<h3 id="heading-wrapping-the-indexed-column-in-a-function">Wrapping the Indexed Column in a Function</h3>
<pre><code class="language-sql">-- Index on email
CREATE INDEX idx_email ON customers (email);

-- ❌ PostgreSQL cannot use the index because of LOWER()
SELECT * FROM customers WHERE LOWER(email) = 'user1@example.com';

-- ✅ Fix: create an expression index on LOWER(email)
CREATE INDEX idx_email_lower ON customers (LOWER(email));
</code></pre>
<p>Any function applied to the indexed column in a WHERE clause prevents the standard index from being used. You need an expression index that matches the function.</p>
<h3 id="heading-implicit-type-casting">Implicit Type Casting</h3>
<pre><code class="language-sql">-- id is an INTEGER column with an index
-- ❌ Passing a string forces a type cast, which may prevent index usage
SELECT * FROM customers WHERE id = '42';

-- ✅ Use the correct type
SELECT * FROM customers WHERE id = 42;
</code></pre>
<p>When the query's value type doesn't match the column type, PostgreSQL may cast the column to match, which prevents index usage.</p>
<h3 id="heading-using-or-conditions-across-different-columns">Using OR Conditions Across Different Columns</h3>
<pre><code class="language-sql">-- ❌ OR across different columns can prevent index usage
SELECT * FROM customers WHERE email = 'user1@example.com' OR city = 'Lagos';

-- ✅ Rewrite as UNION for better index utilization
SELECT * FROM customers WHERE email = 'user1@example.com'
UNION
SELECT * FROM customers WHERE city = 'Lagos';
</code></pre>
<h3 id="heading-leading-wildcards-in-like-queries">Leading Wildcards in LIKE Queries</h3>
<pre><code class="language-sql">-- ❌ Leading wildcard cannot use a B-tree index
SELECT * FROM customers WHERE email LIKE '%@example.com';

-- ✅ Trailing wildcard CAN use a B-tree index
SELECT * FROM customers WHERE email LIKE 'user1%';
</code></pre>
<p>A B-tree index is sorted from left to right. A leading wildcard (<code>%something</code>) means the database can't use the sorted structure and falls back to a sequential scan. If you need to search by suffix or substring, consider a GIN index with the <code>pg_trgm</code> extension.</p>
<h3 id="heading-low-selectivity">Low Selectivity</h3>
<p>If a column has very few distinct values relative to the number of rows (low selectivity), PostgreSQL may decide a sequential scan is faster than using the index.</p>
<p>For example, if a <code>status</code> column has only three possible values (<code>'pending'</code>, <code>'shipped'</code>, <code>'delivered'</code>) and each value covers roughly a third of the table, an index on <code>status</code> alone provides little benefit. PostgreSQL would still need to read a large portion of the table, and the extra index lookup adds overhead.</p>
<p>A partial index is often the better solution in these cases.</p>
<h2 id="heading-best-practices-for-indexing">Best Practices for Indexing</h2>
<p>Here's a summary of the key principles to follow:</p>
<ol>
<li><p><strong>Index columns that appear in WHERE, JOIN, and ORDER BY clauses.</strong> These are the columns the database needs to search, match, or sort by. Start with the queries that run most frequently or take the longest.</p>
</li>
<li><p><strong>Measure before and after with EXPLAIN ANALYZE.</strong> Never add an index based on guesswork. Run your query with <code>EXPLAIN ANALYZE</code>, add the index, and run it again. If the execution time doesn't improve meaningfully, the index isn't helping.</p>
</li>
<li><p><strong>Don't index every column.</strong> Each index slows down writes and consumes storage. Be deliberate about which columns you index based on actual query patterns.</p>
</li>
<li><p><strong>Use composite indexes for multi-column filters.</strong> If your queries commonly filter on <code>city</code> and <code>last_name</code> together, a composite index on <code>(city, last_name)</code> is more efficient than two separate single-column indexes.</p>
</li>
<li><p><strong>Put the most selective column first in composite indexes.</strong> The column that narrows the results the most should come first.</p>
</li>
<li><p><strong>Use partial indexes when you only query a subset of data.</strong> If 90% of your queries target rows where <code>status = 'active'</code>, a partial index on that subset is smaller and faster than a full index.</p>
</li>
<li><p><strong>Monitor index usage regularly.</strong> Query <code>pg_stat_user_indexes</code> to find unused indexes and remove them.</p>
</li>
<li><p><strong>Rebuild bloated indexes periodically.</strong> On tables with heavy update/delete activity, indexes can become bloated. Use <code>REINDEX CONCURRENTLY</code> on production systems.</p>
</li>
</ol>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you learned what database indexes are and why they matter for query performance. You explored how B-tree indexes work under the hood, created several types of indexes (single-column, composite, partial, expression, and unique), and used <code>EXPLAIN ANALYZE</code> to measure the impact.</p>
<p>You also learned about the trade-offs indexes introduce — write overhead, storage cost, and memory pressure — and the common mistakes that silently prevent PostgreSQL from using your indexes.</p>
<p>The core principle is simple: index deliberately based on your actual query patterns, measure the results, and remove anything that isn't pulling its weight.</p>
<p>If you found this tutorial helpful, you can find more of my writing on <a href="https://freecodecamp.org/news/author/iyiola">freeCodeCamp</a> and connect with me on <a href="https://linkedin.com/in/iyioladev">LinkedIn</a> and <a href="https://x.com/iyiola_dev_">X</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ What Are Database Triggers? A Practical Introduction with PostgreSQL Examples ]]>
                </title>
                <description>
                    <![CDATA[ If you've ever needed your database to automatically respond to changes – like logging every update to a sensitive table, enforcing a business rule before an insert, or syncing derived data after a de ]]>
                </description>
                <link>https://www.freecodecamp.org/news/what-are-database-triggers-practical-intro-with-postgresql-examples/</link>
                <guid isPermaLink="false">69c6d1357cf270651037755c</guid>
                
                    <category>
                        <![CDATA[ Databases ]]>
                    </category>
                
                    <category>
                        <![CDATA[ PostgreSQL ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ iyiola ]]>
                </dc:creator>
                <pubDate>Fri, 27 Mar 2026 18:49:25 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/b5940820-d1aa-4d10-8b40-06005bec7e60.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've ever needed your database to automatically respond to changes – like logging every update to a sensitive table, enforcing a business rule before an insert, or syncing derived data after a delete – then triggers are the tool you're looking for.</p>
<p>A database trigger is a function that the database executes automatically when a specific event occurs on a table. You don't call it manually. Instead, you define the conditions, and the database handles the rest.</p>
<p>In this tutorial, you'll learn what triggers are, how they work, when to use them, and when to avoid them. You'll work through practical examples using PostgreSQL, but the core concepts apply to most relational databases.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-how-triggers-work">How Triggers Work</a></p>
</li>
<li><p><a href="#heading-how-to-create-your-first-trigger">How to Create Your First Trigger</a></p>
</li>
<li><p><a href="#heading-before-vs-after-triggers">BEFORE vs AFTER Triggers</a></p>
</li>
<li><p><a href="#heading-how-to-build-an-audit-log-with-an-after-trigger">How to Build an Audit Log with an AFTER Trigger</a></p>
</li>
<li><p><a href="#heading-how-to-use-a-before-trigger-for-validation">How to Use a BEFORE Trigger for Validation</a></p>
</li>
<li><p><a href="#heading-row-level-vs-statement-level-triggers">Row-Level vs Statement-Level Triggers</a></p>
</li>
<li><p><a href="#heading-the-new-and-old-variables-reference">The NEW and OLD Variables Reference</a></p>
</li>
<li><p><a href="#heading-how-to-manage-triggers">How to Manage Triggers</a></p>
</li>
<li><p><a href="#heading-when-to-use-triggers">When to Use Triggers</a></p>
</li>
<li><p><a href="#heading-when-to-avoid-triggers">When to Avoid Triggers</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along with the examples, you'll need:</p>
<ul>
<li><p>Basic knowledge of SQL (SELECT, INSERT, UPDATE, DELETE)</p>
</li>
<li><p>A running PostgreSQL instance (version 12 or later)</p>
</li>
<li><p>A SQL client like <code>psql</code>, pgAdmin, or DBeaver</p>
</li>
</ul>
<p>If you don't have PostgreSQL installed, you can use a free cloud-hosted instance from services like <a href="https://neon.tech">Neon</a> or <a href="https://supabase.com">Supabase</a> to follow along.</p>
<h2 id="heading-how-triggers-work">How Triggers Work</h2>
<p>At a high level, a trigger has three parts:</p>
<ol>
<li><p><strong>The event</strong>: what action activates the trigger (INSERT, UPDATE, DELETE, or TRUNCATE)</p>
</li>
<li><p><strong>The timing</strong>: when the trigger fires relative to the event (BEFORE or AFTER)</p>
</li>
<li><p><strong>The function</strong>: what logic runs when the trigger fires</p>
</li>
</ol>
<p>Here's the general flow: a user or application performs an operation on a table, the database checks if any triggers are associated with that operation, and if a match is found, the database executes the trigger function automatically.</p>
<p>You can think of triggers as event listeners for your database. Just like a JavaScript <code>addEventListener</code> watches for a click or keypress, a database trigger watches for row-level changes on a table.</p>
<h2 id="heading-how-to-create-your-first-trigger">How to Create Your First Trigger</h2>
<p>In PostgreSQL, creating a trigger is a two-step process. You first create a trigger function, then you attach that function to a table with a <code>CREATE TRIGGER</code> statement.</p>
<p>Let's build a concrete example. Say you have a <code>products</code> table and you want to automatically set the <code>updated_at</code> timestamp every time a row is modified.</p>
<h3 id="heading-step-1-create-the-table">Step 1 – Create the Table</h3>
<pre><code class="language-sql">CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    price NUMERIC(10, 2) NOT NULL,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);
</code></pre>
<h3 id="heading-step-2-create-the-trigger-function">Step 2 – Create the Trigger Function</h3>
<p>A trigger function in PostgreSQL is a special function that returns the <code>TRIGGER</code> type. Inside the function body, you have access to two important variables: <code>NEW</code> (the row after the operation) and <code>OLD</code> (the row before the operation).</p>
<pre><code class="language-sql">CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
</code></pre>
<p>This function sets the <code>updated_at</code> column to the current timestamp every time it runs. It then returns <code>NEW</code>, which tells PostgreSQL to proceed with the modified row.</p>
<h3 id="heading-step-3-attach-the-trigger-to-the-table">Step 3 – Attach the Trigger to the Table</h3>
<pre><code class="language-sql">CREATE TRIGGER trigger_set_updated_at
BEFORE UPDATE ON products
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
</code></pre>
<p>Let's break down each part of this statement:</p>
<ul>
<li><p><code>BEFORE UPDATE</code> – the trigger fires before the update is applied to the table</p>
</li>
<li><p><code>ON products</code> – the trigger is associated with the <code>products</code> table</p>
</li>
<li><p><code>FOR EACH ROW</code> – the function runs once for every row affected by the update</p>
</li>
<li><p><code>EXECUTE FUNCTION set_updated_at()</code> – the function to call</p>
</li>
</ul>
<h3 id="heading-step-4-test-it">Step 4 – Test It</h3>
<pre><code class="language-sql">INSERT INTO products (name, price) VALUES ('Wireless Keyboard', 49.99);

-- Wait a moment, then update the row
UPDATE products SET price = 44.99 WHERE name = 'Wireless Keyboard';

SELECT name, price, created_at, updated_at FROM products;
</code></pre>
<p>You'll see that <code>updated_at</code> has been automatically updated to the time of the UPDATE operation, even though you didn't explicitly set it in your query. That's the trigger doing its job.</p>
<h2 id="heading-before-vs-after-triggers">BEFORE vs AFTER Triggers</h2>
<p>The timing of a trigger determines when the function executes relative to the actual data change.</p>
<p><strong>BEFORE triggers</strong> run before the row is inserted, updated, or deleted. They are useful when you want to modify or validate the incoming data. Since the change hasn't been applied yet, you can alter the <code>NEW</code> row or even cancel the operation entirely by returning <code>NULL</code>.</p>
<p><strong>AFTER triggers</strong> run after the row change has been committed to the table. They are useful for side effects like logging, sending notifications, or updating related tables. At this point, the change is already done, so you can't modify the row – but you can read both <code>OLD</code> and <code>NEW</code> to see what changed.</p>
<p>Here's a rule of thumb: use BEFORE triggers when you need to change or reject data, and use AFTER triggers when you need to react to a completed change.</p>
<h2 id="heading-how-to-build-an-audit-log-with-an-after-trigger">How to Build an Audit Log with an AFTER Trigger</h2>
<p>One of the most common uses for triggers is audit logging – keeping a record of every change made to an important table. Let's build one.</p>
<h3 id="heading-step-1-create-an-audit-table">Step 1 – Create an Audit Table</h3>
<pre><code class="language-sql">CREATE TABLE product_audit (
    audit_id SERIAL PRIMARY KEY,
    product_id INT NOT NULL,
    action VARCHAR(10) NOT NULL,
    old_price NUMERIC(10, 2),
    new_price NUMERIC(10, 2),
    changed_by TEXT DEFAULT current_user,
    changed_at TIMESTAMP DEFAULT NOW()
);
</code></pre>
<h3 id="heading-step-2-create-the-audit-trigger-function">Step 2 – Create the Audit Trigger Function</h3>
<pre><code class="language-sql">CREATE OR REPLACE FUNCTION log_product_changes()
RETURNS TRIGGER AS $$
BEGIN
    IF TG_OP = 'UPDATE' THEN
        INSERT INTO product_audit (product_id, action, old_price, new_price)
        VALUES (OLD.id, 'UPDATE', OLD.price, NEW.price);
    ELSIF TG_OP = 'DELETE' THEN
        INSERT INTO product_audit (product_id, action, old_price)
        VALUES (OLD.id, 'DELETE', OLD.price);
    ELSIF TG_OP = 'INSERT' THEN
        INSERT INTO product_audit (product_id, action, new_price)
        VALUES (NEW.id, 'INSERT', NEW.price);
    END IF;

    RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
</code></pre>
<p>There are a few important things happening here. The <code>TG_OP</code> variable is a special string that PostgreSQL provides inside trigger functions. It tells you which operation activated the trigger: <code>'INSERT'</code>, <code>'UPDATE'</code>, or <code>'DELETE'</code>. This lets you handle different operations with a single function.</p>
<p>The <code>RETURN COALESCE(NEW, OLD)</code> at the end ensures the function returns the correct row. For INSERT and UPDATE operations, <code>NEW</code> exists and is returned. For DELETE operations, <code>NEW</code> is null, so <code>OLD</code> is returned instead.</p>
<h3 id="heading-step-3-attach-the-trigger">Step 3 – Attach the Trigger</h3>
<pre><code class="language-sql">CREATE TRIGGER trigger_product_audit
AFTER INSERT OR UPDATE OR DELETE ON products
FOR EACH ROW
EXECUTE FUNCTION log_product_changes();
</code></pre>
<p>Notice the <code>AFTER INSERT OR UPDATE OR DELETE</code> syntax. You can bind a single trigger to multiple events, which keeps your setup clean.</p>
<h3 id="heading-step-4-test-it">Step 4 – Test It</h3>
<pre><code class="language-sql">-- Insert a new product
INSERT INTO products (name, price) VALUES ('USB-C Hub', 29.99);

-- Update the price
UPDATE products SET price = 24.99 WHERE name = 'USB-C Hub';

-- Delete the product
DELETE FROM products WHERE name = 'USB-C Hub';

-- Check the audit log
SELECT * FROM product_audit ORDER BY changed_at;
</code></pre>
<p>You'll see three rows in <code>product_audit</code> (one for each operation) with the old and new prices recorded automatically. No application code needed.</p>
<h2 id="heading-how-to-use-a-before-trigger-for-validation">How to Use a BEFORE Trigger for Validation</h2>
<p>Triggers can also enforce business rules at the database level. Let's say you want to prevent any product from having a negative price.</p>
<pre><code class="language-sql">CREATE OR REPLACE FUNCTION prevent_negative_price()
RETURNS TRIGGER AS $$
BEGIN
    IF NEW.price &lt; 0 THEN
        RAISE EXCEPTION 'Product price cannot be negative. Got: %', NEW.price;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trigger_check_price
BEFORE INSERT OR UPDATE ON products
FOR EACH ROW
EXECUTE FUNCTION prevent_negative_price();
</code></pre>
<p>Now test it:</p>
<pre><code class="language-sql">INSERT INTO products (name, price) VALUES ('Faulty Item', -10.00);
-- ERROR: Product price cannot be negative. Got: -10.00
</code></pre>
<p>The insert is rejected entirely. The row never makes it into the table. This is powerful because the rule is enforced at the database level regardless of which application or script sends the query.</p>
<h2 id="heading-row-level-vs-statement-level-triggers">Row-Level vs Statement-Level Triggers</h2>
<p>All the triggers you've seen so far use <code>FOR EACH ROW</code>, which means the function runs once per affected row. If you update 100 rows in a single query, the trigger function runs 100 times.</p>
<p>PostgreSQL also supports <code>FOR EACH STATEMENT</code> triggers, which run once per SQL statement regardless of how many rows are affected.</p>
<pre><code class="language-sql">CREATE OR REPLACE FUNCTION log_bulk_update()
RETURNS TRIGGER AS $$
BEGIN
    RAISE NOTICE 'A bulk operation was performed on the products table';
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trigger_bulk_update_notice
AFTER UPDATE ON products
FOR EACH STATEMENT
EXECUTE FUNCTION log_bulk_update();
</code></pre>
<p>Statement-level triggers are less common, but they're useful for operations like refreshing a materialized view or sending a single notification after a batch update instead of one notification per row.</p>
<p><strong>Important</strong>: in statement-level triggers, the <code>NEW</code> and <code>OLD</code> variables are not available because the trigger isn't tied to any specific row.</p>
<h2 id="heading-the-new-and-old-variables-reference">The NEW and OLD Variables Reference</h2>
<p>Here's a quick reference for when <code>NEW</code> and <code>OLD</code> are available in row-level triggers:</p>
<table>
<thead>
<tr>
<th>Operation</th>
<th>OLD</th>
<th>NEW</th>
</tr>
</thead>
<tbody><tr>
<td>INSERT</td>
<td>Not available</td>
<td>Contains the new row</td>
</tr>
<tr>
<td>UPDATE</td>
<td>Contains the row before the change</td>
<td>Contains the row after the change</td>
</tr>
<tr>
<td>DELETE</td>
<td>Contains the deleted row</td>
<td>Not available</td>
</tr>
</tbody></table>
<p>Understanding when each variable is available will save you from runtime errors in your trigger functions.</p>
<h2 id="heading-how-to-manage-triggers">How to Manage Triggers</h2>
<p>As you add more triggers to your database, you'll need to know how to inspect, disable, and remove them.</p>
<h3 id="heading-how-to-list-all-triggers-on-a-table">How to List All Triggers on a Table</h3>
<pre><code class="language-sql">SELECT trigger_name, event_manipulation, action_timing
FROM information_schema.triggers
WHERE event_object_table = 'products';
</code></pre>
<h3 id="heading-how-to-disable-a-trigger-temporarily">How to Disable a Trigger Temporarily</h3>
<pre><code class="language-sql">-- Disable a specific trigger
ALTER TABLE products DISABLE TRIGGER trigger_product_audit;

-- Disable all triggers on a table
ALTER TABLE products DISABLE TRIGGER ALL;
</code></pre>
<p>This is useful during bulk data migrations where you want to skip trigger execution for performance reasons.</p>
<h3 id="heading-how-to-re-enable-a-trigger">How to Re-Enable a Trigger</h3>
<pre><code class="language-sql">ALTER TABLE products ENABLE TRIGGER trigger_product_audit;
</code></pre>
<h3 id="heading-how-to-drop-a-trigger">How to Drop a Trigger</h3>
<pre><code class="language-sql">DROP TRIGGER IF EXISTS trigger_product_audit ON products;
</code></pre>
<p>Note that dropping a trigger does not drop the associated function. You'll need to drop the function separately if you no longer need it:</p>
<pre><code class="language-sql">DROP FUNCTION IF EXISTS log_product_changes();
</code></pre>
<h2 id="heading-when-to-use-triggers">When to Use Triggers</h2>
<p>Triggers work well for specific use cases. Here are the scenarios where they're a strong choice:</p>
<ul>
<li><p><strong>Audit logging</strong>: automatically recording who changed what and when, as you saw earlier in this tutorial.</p>
</li>
<li><p><strong>Derived data maintenance</strong>: keeping computed columns, counters, or summary tables in sync with the source data.</p>
</li>
<li><p><strong>Data validation</strong>: enforcing business rules that go beyond what CHECK constraints can express, like cross-table validations.</p>
</li>
<li><p><strong>Automatic timestamping</strong>: setting <code>created_at</code> and <code>updated_at</code> fields without relying on the application layer.</p>
</li>
</ul>
<h2 id="heading-when-to-avoid-triggers">When to Avoid Triggers</h2>
<p>Triggers are powerful, but they come with trade-offs. Here are cases where you should think twice before using them:</p>
<ul>
<li><p><strong>Complex business logic</strong>: if the logic involves calling external APIs, sending emails, or orchestrating multi-step workflows, it belongs in your application layer. Triggers should stay lightweight.</p>
</li>
<li><p><strong>Performance-sensitive bulk operations</strong>: row-level triggers on tables that frequently receive bulk inserts or updates can create significant overhead. If you're inserting millions of rows, those triggers fire millions of times.</p>
</li>
<li><p><strong>Cascading triggers</strong>: when one trigger's action fires another trigger, which fires another, debugging becomes extremely difficult. If you find yourself building a chain of triggers, reconsider the design.</p>
</li>
<li><p><strong>Logic that developers need to discover easily</strong>: triggers are sometimes called "hidden logic" because they execute automatically without appearing in application code. If your team frequently asks "why did this column change?" and the answer is always "there's a trigger," that's a sign the logic might be more discoverable if placed in your application layer or a stored procedure that's called explicitly.</p>
</li>
</ul>
<p>A good rule of thumb: if the logic is tightly coupled to the data and should always execute regardless of which client or service touches the table, a trigger is appropriate. If the logic depends on application context (like the current user's session, feature flags, or external state), it belongs in the application.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you learned what database triggers are and how they work in PostgreSQL. You built three practical triggers: an automatic timestamp updater, a full audit logging system, and a data validation guard. You also learned the difference between BEFORE and AFTER triggers, row-level and statement-level triggers, and when <code>NEW</code> and <code>OLD</code> variables are available.</p>
<p>Triggers are a powerful tool for keeping your data consistent and your business rules enforced at the database level. Use them for focused, data-centric operations, and keep the logic simple.</p>
<p>If you found this tutorial helpful, you can connect with me on <a href="https://linkedin.com/in/iyioladev">LinkedIn</a> and <a href="https://x.com/iyiola_dev_">X</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ An Introduction to Database System Design ]]>
                </title>
                <description>
                    <![CDATA[ These days, businesses and startups rely on well-designed databases to manage vast amounts of data. In domains like Healthcare, E-commerce, and Fintech/Banking, a solid database design ensures data in ]]>
                </description>
                <link>https://www.freecodecamp.org/news/an-introduction-to-database-system-design/</link>
                <guid isPermaLink="false">69c46ab210e664c5da06ac46</guid>
                
                    <category>
                        <![CDATA[ Databases ]]>
                    </category>
                
                    <category>
                        <![CDATA[ DBMS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ database design ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Olasunkanmi Emmanuel Jesuferanmi ]]>
                </dc:creator>
                <pubDate>Wed, 25 Mar 2026 23:07:30 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/e0c4195f-9f09-45f4-99d1-977c02731b02.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>These days, businesses and startups rely on well-designed databases to manage vast amounts of data. In domains like Healthcare, E-commerce, and Fintech/Banking, a solid database design ensures data integrity, security, and accessibility.</p>
<p>In this article, we'll talk about what it takes to design a highly-functional database using some key best practices.</p>
<p>This article is aimed at developers and those looking to start a career in managing Databases. We'll discuss what a database actually is, the components of a Database System, what we mean by database design, the stages of database design, and what's involved in Database System Design.</p>
<h3 id="heading-table-of-contents">Table of Contents</h3>
<ol>
<li><p><a href="#heading-prerequisites-and-setup">Prerequisites and Setup</a></p>
<ul>
<li><p><a href="#heading-1-foundational-knowledge">1. Foundational Knowledge</a></p>
</li>
<li><p><a href="#heading-2-software-and-installation">2. Software and Installation</a></p>
</li>
<li><p><a href="#heading-3-verifying-your-setup">3. Verifying Your Setup</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-is-a-database">What is a Database?</a></p>
</li>
<li><p><a href="#heading-components-of-a-database-system">Components of a Database System</a></p>
</li>
<li><p><a href="#heading-types-of-database-systems">Types of Database Systems</a></p>
</li>
<li><p><a href="#heading-database-system-vs-dbms">Database System vs. DBMS</a></p>
</li>
<li><p><a href="#heading-characteristics-of-a-good-database">Characteristics of a Good Database</a></p>
</li>
<li><p><a href="#heading-stages-of-database-design">Stages of Database Design</a></p>
</li>
<li><p><a href="#heading-the-role-of-normalisation">The Role of Normalisation</a></p>
</li>
<li><p><a href="#heading-practical-designing-a-library-system">Practical: Designing a Library System</a></p>
<ul>
<li><p><a href="#heading-step-1-requirements--er-diagram">Step 1: Requirements &amp; ER Diagram</a></p>
</li>
<li><p><a href="#heading-step-2-normalization-in-action">Step 2: Normalization in Action</a></p>
</li>
<li><p><a href="#heading-step-3-implementation-sql">Step 3: Implementation (SQL)</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-prerequisites-and-setup">Prerequisites and Setup</h2>
<p>To get the most out of this guide, you should have the following foundational skills and tools ready. This will help ensure that you aren't just reading theory, but that you're actually building a functional system.</p>
<h3 id="heading-1-foundational-knowledge">1. Foundational Knowledge</h3>
<ul>
<li><p><strong>Data Types:</strong> You should be able to distinguish between basic data formats. In database design, choosing the wrong type can lead to storage waste or application errors.</p>
<ul>
<li><p><strong>Strings/Varchars:</strong> Textual data (for example, "John Doe", "123 Main St").</p>
</li>
<li><p><strong>Integers:</strong> Whole numbers used for math or unique IDs (for example, 10, 500).</p>
</li>
<li><p><strong>Floats/Decimals:</strong> Numbers with decimal points, usually for currency (for example, 19.99).</p>
</li>
<li><p><strong>Booleans:</strong> Simple True/False toggles (for example, <code>is_available</code>).</p>
</li>
</ul>
</li>
<li><p><strong>Logical Thinking:</strong> You should be comfortable identifying "entities." If you're building an app for a school, you'll need to recognize that "Students," "Teachers," and "Classrooms" are separate objects that must be linked via relationships.</p>
</li>
<li><p><strong>Terminal/CLI Basics:</strong> While we'll use visual tools, you should know how to open your Command Prompt (Windows) or Terminal (Mac/Linux) and understand that commands are often case-sensitive.</p>
</li>
</ul>
<h3 id="heading-2-software-and-installation">2. Software and Installation</h3>
<p>We'll use <strong>PostgreSQL</strong> (the database engine) and <strong>pgAdmin 4</strong> (the visual management tool).</p>
<ol>
<li><p><strong>Download:</strong> Visit the <a href="https://www.postgresql.org/download/">official PostgreSQL Downloads page</a> and select the installer for your operating system.</p>
</li>
<li><p><strong>Installation Wizard:</strong> Run the installer. When asked which components to include, make sure that PostgreSQL Server, pgAdmin 4, and Command Line Tools are all checked.</p>
</li>
<li><p><strong>The "Postgres" User:</strong> During setup, you will be prompted to create a password for the default "postgres" superuser. <strong>Note:</strong> Write this password down. You can't easily reset it, and you'll need it to access your data.</p>
</li>
<li><p><strong>Port Selection:</strong> The default port is <code>5432</code>. Keep this as the default unless you're an advanced user with a specific reason to change it.</p>
</li>
</ol>
<h3 id="heading-3-verifying-your-setup">3. Verifying Your Setup</h3>
<p>Before moving to the practical section, let's verify that everything is installed correctly:</p>
<ol>
<li><p>Open <strong>pgAdmin 4</strong> from your applications menu.</p>
</li>
<li><p>In the left-hand sidebar, click on <strong>Servers</strong>.</p>
</li>
<li><p>Enter the master password you created during installation.</p>
</li>
<li><p>If you see "PostgreSQL [Version Number]" appear with a green icon, your database environment is successfully configured.</p>
</li>
</ol>
<h2 id="heading-what-is-a-database">What is a Database?</h2>
<p>A Database is a collection of structured data usually stored electronically in a computer. Databases are controlled and managed using a <strong>Database Management System (DBMS).</strong> Database Management Software is an application that constructs and maintain (and sometimes expands) Databases. Examples of DBMS are IBM's DB2, Oracle Corporation's Oracle, Microsoft Access, and Microsoft's SQL Server.</p>
<p>We use databases everyday, whether knowingly or unknowingly. And as a developer, you'll likely need to at least understand Database basics so you can effectively work with them.</p>
<p>It's also important for you to how to know how to design a scalable database, as well as be familiar with the environment in which the database will be housed (called, not surprisingly, a <strong>Database Environment</strong>). The hardware and operating system that house the database makes up this Database Environment.</p>
<h3 id="heading-components-of-a-database-system">Components of a Database System</h3>
<p>A Database System is a computerized record-keeping system designed to store, manage, and retrieve data efficiently. It acts as a centralized repository that allows multiple users to access and manipulate data simultaneously while ensuring the integrity, security, and persistence of that information over time.</p>
<p>A Database System consists of four basic components:</p>
<h4 id="heading-1-hardware">1. Hardware</h4>
<p>This includes the secondary storage where the database resides alongside other necessary components. Examples are Hard disks, Processors, RAM, and so on. Since a database can span from a single workstation to a global mainframe, hardware selection is a priority. Proper investment in processing power and storage is essential to handling the projected user load and data volume.</p>
<h4 id="heading-2-software">2. Software</h4>
<p>In this case, Database Management Software (DBMS) is in charge of the maintenance and management of databases. It's robust software that acts as an intermediary, restricting users from the complex hardware-level details of data storage. The Software layer (DBMS) handles data storage, retrieval, and processing. Examples of DBMS are Microsoft's SQL Server, IBM's DB2, and Oracle.</p>
<h4 id="heading-3-data"><strong>3. Data</strong></h4>
<p>Data serves as the bridge connecting the machine components (hardware and software) to the human users. In a database system, data is organized into two main types:</p>
<ul>
<li><p><strong>User Data:</strong> The actual structured information stored in tables, made up of columns (attributes) and rows (records).</p>
</li>
<li><p><strong>Metadata:</strong> Often defined as "data about data," metadata is stored in system tables and describes the actual structure of the database, such as the number of tables, field names, and defined primary keys</p>
</li>
</ul>
<h4 id="heading-4-users">4. Users</h4>
<p>These are the people who interact with the database to carry out their business responsibilities. Users generally fall into three distinct categories:</p>
<ul>
<li><p><strong>Database Administrators (DBAs):</strong> Technical experts who hold central responsibility for the database. They monitor performance, define security and integrity checks, and establish backup and recovery strategies.</p>
</li>
<li><p><strong>Database Designers/Programmers:</strong> The engineers who actually write the code and use the DBMS to create the database's logical structure.</p>
</li>
<li><p><strong>End Users:</strong> The everyday people who access the database using query languages or simple menu-driven application interfaces</p>
</li>
</ul>
<h2 id="heading-types-of-database-systems">Types of Database Systems</h2>
<p>It's important to know that not all databases store data in the same way. The choice of database depends on the specific needs of the application. The primary types include:</p>
<h3 id="heading-hierarchical-and-network-databases">Hierarchical and Network Databases</h3>
<p>These are older, legacy models. Hierarchical databases structure data in a tree-like, parent-child format where a child can only have one parent. Network databases improved on this by allowing a graph-like structure where records can have multiple parent and child relationships, making it easier to model complex associations.</p>
<h3 id="heading-relational-databases-rdbms">Relational Databases (RDBMS)</h3>
<p>The most widely used type today. They organize data into structured tables consisting of rows and columns. These tables are linked using primary and foreign keys, and they use Structured Query Language (SQL) for operations. They are ideal for applications requiring strong consistency, like banking systems.</p>
<h3 id="heading-object-oriented-databases-oodbms">Object-Oriented Databases (OODBMS)</h3>
<p>These combine database capabilities with object-oriented programming principles (like Java or C++). Data is stored as "objects" that contain both the data and the methods (functions) that operate on it, making them great for complex data like multimedia or engineering designs.</p>
<h3 id="heading-nosql-databases">NoSQL Databases</h3>
<p>Designed to handle large volumes of unstructured or semi-structured data. Unlike relational databases, they don't rely on rigid table structures and are highly scalable. Types of NoSQL include document stores (for example, MongoDB), key-value stores (for example, Redis), column-family stores, and graph databases.</p>
<h3 id="heading-cloud-and-distributed-databases">Cloud and Distributed Databases</h3>
<p>Cloud databases are hosted on cloud platforms (like AWS or Microsoft Azure) and offer elasticity, scalability, and cost-efficiency (pay-as-you-go).</p>
<p>Distributed databases store data across multiple physical locations but function as a single unified system to the user, providing high availability and fault tolerance.</p>
<h3 id="heading-database-system-vs-database-management-system-dbms">Database System vs. Database Management System (DBMS)</h3>
<p>People often use "Database" and "DBMS" interchangeably, but there is a distinct difference:</p>
<ul>
<li><p><strong>Database Management System (DBMS):</strong> This is purely the <em>software</em> that helps users interact with the database. It handles data storage, retrieval, security, and concurrency control. Examples include MySQL, PostgreSQL, and Oracle DB.</p>
</li>
<li><p><strong>Database System:</strong> This is the <em>broader concept</em> that encompasses the entire setup. It includes the actual database (where data is stored), the DBMS software, the physical hardware, the network, and the users interacting with it.</p>
</li>
</ul>
<h2 id="heading-characteristics-of-a-good-database">Characteristics of a Good Database</h2>
<p>To make sure that your database design is successful, it should exhibit several core characteristics:</p>
<ul>
<li><p><strong>Data integrity and consistency:</strong> Ensuring data is accurate, reliable, and uniform across the entire system.</p>
</li>
<li><p><strong>Data security:</strong> Protecting sensitive information from unauthorized access and potential breaches.</p>
</li>
<li><p><strong>Scalability and performance:</strong> The ability to handle increasing amounts of data and users efficiently while providing fast query processing.</p>
</li>
<li><p><strong>Redundancy management (normalization):</strong> Avoiding unnecessary duplication of data to save storage space and prevent errors during updates.</p>
</li>
<li><p><strong>Concurrency control:</strong> Allowing multiple users to access and modify data simultaneously without causing conflicts or data corruption.</p>
</li>
<li><p><strong>Backup and recovery:</strong> Supporting robust mechanisms to recover data in the event of hardware or system failures.</p>
</li>
</ul>
<h2 id="heading-stages-of-database-design">Stages of Database Design</h2>
<p>Database design is a structured process consisting of several stages to ensure that data is efficiently stored, accessed, and managed. There are four key stages in this process:</p>
<h3 id="heading-requirements-analysis">Requirements Analysis</h3>
<p>This is the foundational stage where designers gather and analyse the specific needs of users and the business. It involves identifying the overall purpose of the database, understanding data requirements, defining key entities and attributes, and establishing both functional and non-functional requirements.</p>
<h3 id="heading-conceptual-design">Conceptual Design</h3>
<p>In this phase, a high-level visual blueprint of the database is created, which is independent of any specific software implementation. This makes it easy for non-technical stakeholders to understand.</p>
<p>Designers typically use <a href="https://www.freecodecamp.org/news/how-to-make-flowcharts-with-mermaid/"><strong>Entity-Relationship (ER) models</strong></a> or <a href="https://www.freecodecamp.org/news/uml-diagrams-full-course/">UML diagrams</a> to identify entities, map out relationships, and define constraints like primary keys.</p>
<h3 id="heading-logical-design">Logical Design</h3>
<p>This stage involves translating the conceptual blueprint into a logical model that aligns with a specific type of Database Management System (DBMS), such as a relational or NoSQL system.</p>
<p>Key steps include converting the ER diagram into relational schemas (tables and columns), defining foreign and primary keys, and normalising the database to remove anomalies and reduce data redundancy.</p>
<h3 id="heading-physical-design">Physical Design</h3>
<p>The final stage translates the logical model into an actual physical structure optimized for high performance and efficient storage. Activities here include selecting the specific DBMS, establishing indexing strategies to speed up data retrieval, defining access paths, and configuring essential security policies and backup mechanisms.</p>
<h2 id="heading-the-role-of-normalisation-in-database-design">The Role of Normalisation in Database Design</h2>
<p>When building a relational database, one of the most critical steps in the logical design phase is a process called <strong>Normalisation</strong>.</p>
<p>Normalisation is a systematic approach to organising data to minimise redundancy (duplicate data) and improve overall data integrity. Essentially, it involves taking large, clunky tables and decomposing them into smaller, more focused tables, then linking them together using defined relationships.</p>
<h3 id="heading-why-is-normalisation-important">Why is Normalisation Important?</h3>
<p>A poorly designed database often suffers from errors that happen when you try to insert, update, or delete data. For example, if a teacher's phone number is stored in multiple places, updating it in one row but forgetting another creates an update anomaly. Normalisation solves this by ensuring each piece of information is stored in only one place.</p>
<p>The main objectives of normalisation are:</p>
<ul>
<li><p><strong>Eliminating redundancy:</strong> By reducing duplicate data, you save valuable storage space and keep your data consistent.</p>
</li>
<li><p><strong>Avoiding anomalies:</strong> It prevents data corruption that arises during insertion, updating, or deletion.</p>
</li>
<li><p><strong>Ensuring data integrity:</strong> It maintains the accuracy and reliability of the data across the entire database.</p>
</li>
<li><p><strong>Enhancing query performance:</strong> Organising the data efficiently helps optimise how data is retrieved and updated.</p>
</li>
</ul>
<h3 id="heading-stages-of-normalisation">Stages of Normalisation</h3>
<p>Normalisation happens in sequential stages known as <strong>Normal Forms (NFs)</strong>, where each stage builds upon the rules of the previous one to further refine the database structure.</p>
<p>For beginners, the first three forms are the most important to understand:</p>
<ul>
<li><p><strong>First Normal Form (1NF):</strong> This stage ensures "atomicity". This means that every column in a table should hold a single, indivisible value, and duplicate columns are eliminated. For instance, you would not store two different phone numbers in a single "Phone" cell; you would separate them.</p>
</li>
<li><p><strong>Second Normal Form (2NF):</strong> To achieve 2NF, the table must first be in 1NF. Then, it ensures that all non-key attributes (the regular data columns) are fully dependent on the entire primary key. This often involves creating separate tables for distinct entities, like putting "Courses" into their own table rather than mixing them with "Student" details.</p>
</li>
<li><p><strong>Third Normal Form (3NF):</strong> A table in 3NF is already in 2NF and has removed all "transitive dependencies". This means that a non-key column shouldn't depend on another non-key column. For example, if a table has an "Instructor Name" and "Instructor Phone," those details should live in a dedicated "Instructor" table, not inside a "Course" table.</p>
</li>
<li><p><strong>Boyce-Codd Normal Form (BCNF):</strong> This is a stricter version of 3NF used to resolve any remaining, complex anomalies.</p>
</li>
</ul>
<h3 id="heading-finding-the-right-balance">Finding the Right Balance</h3>
<p>While normalisation is crucial for maintaining data consistency, it's important to strike a balance. Minimising redundancy is great, but excessive normalisation creates dozens of tiny tables. When you need to retrieve a complete record, the database system has to piece all those tables back together (using complex queries called "joins"), which can slow down performance.</p>
<p>So the ultimate goal of a good database designer is to find the sweet spot between a highly normalised structure and efficient query performance.</p>
<h2 id="heading-practical-designing-a-library-system">Practical: Designing a Library System</h2>
<p>To move from theory to practice, let’s build a database for a small local library. We'll go through the design stages to ensure the data is structured efficiently.</p>
<h3 id="heading-step-1-requirements-amp-er-diagram">Step 1: Requirements &amp; ER Diagram</h3>
<p>First, we'll identify what our library needs to track. We have three main entities:</p>
<ul>
<li><p><strong>Authors:</strong> The writers of the books.</p>
</li>
<li><p><strong>Books:</strong> The actual items available for loan.</p>
</li>
<li><p><strong>Members:</strong> The people who borrow the books.</p>
</li>
</ul>
<p><strong>The Relationships:</strong></p>
<ul>
<li><p>One <strong>Author</strong> can write many <strong>Books</strong> (One-to-Many).</p>
</li>
<li><p>One <strong>Member</strong> can borrow many <strong>Books</strong> (One-to-Many).</p>
</li>
</ul>
<p>Here's the ER diagram I've created for this example:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f1de20cf4016901885ccc17/fde8b5a3-c546-4426-b5e3-9a6901c9553d.png" alt="ER diagram" style="display:block;margin:0 auto" width="831" height="537" loading="lazy">

<h3 id="heading-step-2-normalization-in-action">Step 2: Normalization in Action</h3>
<p>To ensure our database is "well-designed" and free of redundancy, we'll apply the normalization rules discussed earlier. Instead of one giant spreadsheet, we split the data into three distinct tables:</p>
<ol>
<li><p><strong>Authors Table:</strong> * <code>author_id</code> (Primary Key)</p>
<ul>
<li><code>author_name</code></li>
</ul>
</li>
<li><p><strong>Books Table:</strong> * <code>book_id</code> (Primary Key)</p>
<ul>
<li><p><code>title</code></p>
</li>
<li><p><code>isbn</code></p>
</li>
<li><p><code>author_id</code> (Foreign Key linking to the Authors table)</p>
</li>
</ul>
</li>
<li><p><strong>Members Table:</strong> * <code>member_id</code> (Primary Key)</p>
<ul>
<li><p><code>first_name</code></p>
</li>
<li><p><code>last_name</code></p>
</li>
<li><p><code>email</code> (Unique constraint)</p>
</li>
</ul>
</li>
</ol>
<h3 id="heading-step-3-implementation-sql">Step 3: Implementation (SQL)</h3>
<p>Now, let’s use the <strong>PostgreSQL Query Tool</strong> in pgAdmin 4 to actually create these tables and insert some dummy data.</p>
<pre><code class="language-sql">-- 1. Create the Authors table
CREATE TABLE Authors (
    author_id SERIAL PRIMARY KEY,
    author_name VARCHAR(100) NOT NULL
);

-- 2. Create the Books table with a relationship to Authors
CREATE TABLE Books (
    book_id SERIAL PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    isbn VARCHAR(20) UNIQUE,
    author_id INT REFERENCES Authors(author_id)
);

-- 3. Create the Members table
CREATE TABLE Members (
    member_id SERIAL PRIMARY KEY,
    first_name VARCHAR(50),
    last_name VARCHAR(50),
    email VARCHAR(100) UNIQUE NOT NULL
);

-- 4. Insert dummy data to test the design
INSERT INTO Authors (author_name) 
VALUES ('J.R.R. Tolkien'), ('George R.R. Martin');

INSERT INTO Books (title, isbn, author_id) 
VALUES ('The Hobbit', '978-0261102217', 1), 
       ('A Game of Thrones', '978-0553103540', 2);
</code></pre>
<h4 id="heading-understanding-the-schema-design">Understanding the Schema Design:</h4>
<p>By running the SQL script above, you’ve successfully transitioned from a logical design to a physical database. Here is a breakdown of the key concepts we applied:</p>
<ul>
<li><p><strong>Primary Keys (PK):</strong> Using <code>SERIAL PRIMARY KEY</code> automatically creates a unique, incrementing ID for every new entry. This ensures no two authors or books are ever confused by the system.</p>
</li>
<li><p><strong>Foreign Keys (FK):</strong> The <code>REFERENCES Authors(author_id)</code> command is where the "Relational" part of a Relational Database happens. It tells the <code>Books</code> table that it must point to a valid ID in the <code>Authors</code> table, preventing "orphan" books without creators.</p>
</li>
<li><p><strong>Constraints:</strong> By using <code>UNIQUE</code> on the <code>isbn</code> and <code>email</code> columns, we've programmed the database to reject any duplicate data, ensuring high data integrity.</p>
</li>
</ul>
<h3 id="heading-how-to-fetch-your-data">How to Fetch Your Data</h3>
<p>Now that the data is stored, you need to know how to get it back out. In SQL, we can do this using the <code>SELECT</code> statement.</p>
<h4 id="heading-1-see-everything-in-a-table">1. See Everything in a Table</h4>
<p>To see all books currently in the library:</p>
<pre><code class="language-sql">SELECT * FROM Books;
</code></pre>
<h4 id="heading-2-filtering-results">2. Filtering Results</h4>
<p>Often, you don't want every single row. You can use the <code>WHERE</code> clause to filter for specific data. For example, to find an author by their exact name:</p>
<pre><code class="language-sql">SELECT * FROM Authors 
WHERE author_name = 'J.R.R. Tolkien';
</code></pre>
<h4 id="heading-3-joining-tables">3. Joining Tables</h4>
<p>In a normalized database, information is spread across tables. To see a list of book titles alongside their actual author names (instead of just an ID number), you use a <code>JOIN</code>.</p>
<pre><code class="language-sql">SELECT Books.title, Authors.author_name
FROM Books
JOIN Authors ON Books.author_id = Authors.author_id;
</code></pre>
<p>The database is instructed by this query to "give the titles from the Books table and the names from the Authors table where the author_id matches in both." This enables you to effectively store data in distinct tables while viewing it as a single, comprehensive report.</p>
<p>This ability to link information across tables is what makes Relational Databases the industry standard for most business applications. But while the relational model is powerful, it isn't the only way to store data. Depending on whether you're handling social media connections, real-time sensor data, or simple document storage, you might need a different architectural approach.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Designing a database is much more than simply throwing information into a computer. It's the process of building a robust, efficient, and secure foundation for decision-making and business operations.</p>
<p>As we have explored here, a successful database relies on a carefully orchestrated ecosystem of hardware, software (the DBMS), data, and users.</p>
<p>By following the four stages of design – Requirements Analysis, Conceptual Design, Logical Design, and Physical Design – you can avoid costly structural mistakes and ensure that your system perfectly aligns with user needs.</p>
<p>Applying critical techniques like normalisation during this process guarantees that your data remains consistent, accurate, and free from frustrating anomalies.</p>
<p>Also, as the digital landscape continues to evolve, mastering these foundational concepts is your stepping stone into the future. Traditional relational databases remain incredibly powerful, but modern data demands are rapidly driving the adoption of cloud-based, AI-powered, and serverless database systems.</p>
<p>A well-designed system today must not only focus on data integrity and query performance but also prioritise scalability and stringent data security to protect against modern cyber threats.</p>
<p>Whether you are building a simple address book or architecting a backend for the next big application, keeping these core principles of database system design in mind will empower you to create resilient, high-performing, and future-proof data solutions.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Implement the Outbox Pattern in Go and PostgreSQL ]]>
                </title>
                <description>
                    <![CDATA[ In event-driven systems, two things need to happen when you process a request: you need to save data to your database, and you need to publish an event to a message broker so other services know somet ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-implement-the-outbox-pattern-in-go-and-postgresql/</link>
                <guid isPermaLink="false">69bc31b3b238fd45a31f1291</guid>
                
                    <category>
                        <![CDATA[ PostgreSQL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Databases ]]>
                    </category>
                
                    <category>
                        <![CDATA[ golang ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Alex Pliutau ]]>
                </dc:creator>
                <pubDate>Thu, 19 Mar 2026 17:26:11 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/7a24b5a7-6619-4997-b24c-c4a743f37c33.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In event-driven systems, two things need to happen when you process a request: you need to save data to your database, and you need to publish an event to a message broker so other services know something changed.</p>
<p>These two operations look simple, but they hide a dangerous reliability problem. What if the database write succeeds but the message broker is temporarily unreachable? Or your service crashes between the two steps? You end up in an inconsistent state: your database has the new data, but the rest of the system never heard about it.</p>
<p>The <strong>Outbox Pattern</strong> is a well-established solution to this problem. In this tutorial, you'll learn what the pattern is, why it works, and how to implement it in Go with PostgreSQL and Google Cloud Pub/Sub.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before reading this tutorial, you should be familiar with:</p>
<ul>
<li><p>The basics of the Go programming language</p>
</li>
<li><p>SQL and PostgreSQL</p>
</li>
<li><p>The concept of database transactions</p>
</li>
<li><p>Basic familiarity with event-driven or distributed systems (helpful but not required)</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-the-problem-two-operations-no-atomicity">The Problem: Two Operations, No Atomicity</a></p>
</li>
<li><p><a href="#heading-how-the-outbox-pattern-works">How the Outbox Pattern Works</a></p>
</li>
<li><p><a href="#heading-the-outbox-table-schema">The Outbox Table Schema</a></p>
</li>
<li><p><a href="#heading-the-message-relay">The Message Relay</a></p>
</li>
<li><p><a href="#heading-go-and-postgresql-implementation">Go and PostgreSQL Implementation</a></p>
<ul>
<li><p><a href="#heading-the-orders-service">The Orders Service</a></p>
</li>
<li><p><a href="#heading-the-relay-service">The Relay Service</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-why-messages-can-be-delivered-more-than-once">Why Messages Can Be Delivered More Than Once</a></p>
</li>
<li><p><a href="#heading-alternative-postgresql-logical-replication">Alternative: PostgreSQL Logical Replication</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-the-problem-two-operations-no-atomicity">The Problem: Two Operations, No Atomicity</h2>
<p>To understand why the Outbox Pattern exists, you need to understand a core challenge in distributed systems: <strong>atomicity across different systems</strong>.</p>
<p>In a relational database, a <strong>transaction</strong> lets you group multiple operations so they either all succeed or all fail together. If you insert a row and update another row in the same transaction, you're guaranteed that both happen – or neither does.</p>
<p>The problem arises when you try to extend this guarantee <em>across two different systems:</em> for example, your database and your message broker (like Kafka, RabbitMQ, or Pub/Sub). These systems don't share a transaction boundary.</p>
<p>Here's a typical event-driven flow that breaks without the Outbox Pattern:</p>
<ol>
<li><p>A user places an order.</p>
</li>
<li><p>Your service saves the order to the database ✅</p>
</li>
<li><p>Your service publishes an <code>order.created</code> event to the message broker ❌ (broker is down)</p>
</li>
<li><p>The order exists in the database, but downstream services never learned about it.</p>
</li>
</ol>
<p>Or the reverse failure:</p>
<ol>
<li><p>Your service publishes the event first ✅</p>
</li>
<li><p>Your service tries to save the order to the database ❌ (database times out)</p>
</li>
<li><p>Downstream services received a notification for an order that doesn't exist.</p>
</li>
</ol>
<p>Either scenario leaves your system in an inconsistent state. This is the core problem the Outbox Pattern solves.</p>
<p>Here's what the process looks like when not using the Outbox Pattern:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5ea89c91fdc930d846b413ab/9f9abcaa-adc8-48ab-b8cb-c47cb731724e.png" alt="diagram without outbox" style="display:block;margin:0 auto" width="2558" height="1062" loading="lazy">

<h2 id="heading-how-the-outbox-pattern-works">How the Outbox Pattern Works</h2>
<p>The Outbox Pattern solves the atomicity problem by keeping both operations <em>inside</em> the database:</p>
<ol>
<li><p>Saves your business data (for example, a new order) to your database.</p>
</li>
<li><p>Writes the event message to a special table called the outbox table in the same database transaction.</p>
</li>
<li><p>A separate background process called the Message Relay polls the outbox table and publishes pending messages to the broker.</p>
</li>
<li><p>Once the broker confirms receipt, the relay marks the message as processed.</p>
</li>
</ol>
<p>Because steps 1 and 2 happen in the same database transaction, they are <strong>atomic</strong>. Either both succeed or neither does. You can never end up with saved data but no corresponding event queued – or an event queued for data that was never saved.</p>
<p>The message is never published directly to the broker in your main application code. Instead, the database acts as a reliable staging area.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5ea89c91fdc930d846b413ab/ef5413b0-6c8e-42b8-949f-5eefe3844231.png" alt="diagram with outbox" style="display:block;margin:0 auto" width="2478" height="1216" loading="lazy">

<h2 id="heading-the-outbox-table-schema">The Outbox Table Schema</h2>
<p>The outbox table stores pending messages until the relay picks them up. Here's a typical PostgreSQL schema:</p>
<pre><code class="language-sql">CREATE TABLE outbox (
    id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    topic       varchar(255)  NOT NULL,
    message     jsonb         NOT NULL,
    state       varchar(50)   NOT NULL DEFAULT 'pending',
    created_at  timestamptz   NOT NULL DEFAULT now(),
    processed_at timestamptz
);
</code></pre>
<p>Let's walk through each column:</p>
<ul>
<li><p><code>id</code>: A unique identifier for each message. Using UUIDs makes it easy to reference specific messages.</p>
</li>
<li><p><code>topic</code>: The destination topic or queue name in your message broker (for example, <code>orders.created</code>).</p>
</li>
<li><p><code>message</code>: The event payload, stored as JSON. This is the data your consumers will receive.</p>
</li>
<li><p><code>state</code>: Tracks whether the message has been sent. The two main values are <code>pending</code> (waiting to be published) and <code>processed</code> (successfully published).</p>
</li>
<li><p><code>created_at</code>: When the message was inserted. The relay uses this to process messages in order.</p>
</li>
<li><p><code>processed_at</code>: When the relay successfully published the message.</p>
</li>
</ul>
<p>You may want to add additional columns depending on your needs: for example, a <code>retry_count</code> column to track how many times the relay has attempted to send a message, or an <code>error</code> column to log failure reasons.</p>
<h2 id="heading-the-message-relay">The Message Relay</h2>
<p>The Message Relay is a background process (often a goroutine, a sidecar, or a separate service) that bridges the outbox table and the message broker.</p>
<p>Its responsibilities are:</p>
<ol>
<li><p>Periodically query the outbox table for messages with <code>state = 'pending'</code>.</p>
</li>
<li><p>Publish each message to the appropriate topic in the broker.</p>
</li>
<li><p>Once the broker confirms delivery, update the row's <code>state</code> to <code>'processed'</code>.</p>
</li>
<li><p>Handle failures gracefully: if publishing fails, leave the message as <code>'pending'</code> so it will be retried.</p>
</li>
</ol>
<p>This design gives you <strong>at-least-once delivery</strong>: a message will always be sent, even if the relay crashes and restarts. The trade-off is that a message might occasionally be sent more than once (more on this below), so your consumers should handle duplicates.</p>
<h2 id="heading-go-and-postgresql-implementation">Go and PostgreSQL Implementation</h2>
<p>Let's build a concrete example. Imagine you have an orders service. When a new order is created, you want to:</p>
<ol>
<li><p>Save the order to a PostgreSQL <code>orders</code> table.</p>
</li>
<li><p>Publish an <code>order.created</code> event to Google Cloud Pub/Sub.</p>
</li>
</ol>
<p>You'll use <a href="https://github.com/jackc/pgx">pgx</a> for the PostgreSQL driver.</p>
<h3 id="heading-the-orders-service">The Orders Service</h3>
<p>The key insight is that the order insert and the outbox insert happen <strong>inside the same transaction</strong>. If anything goes wrong, both are rolled back.</p>
<pre><code class="language-go">// orders/main.go

package main

import (
	"context"
	"encoding/json"
	"log"
	"os"

	"github.com/google/uuid"
	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"
)

// Order represents a customer order in our system.
type Order struct {
	ID       uuid.UUID `json:"id"`
	Product  string    `json:"product"`
	Quantity int       `json:"quantity"`
}

// OrderCreatedEvent is the payload published to the message broker.
// It contains only the fields that downstream services need to know about.
type OrderCreatedEvent struct {
	OrderID uuid.UUID `json:"order_id"`
	Product string    `json:"product"`
}

// createOrderInTx saves a new order and its outbox event atomically.
// Both operations share the same transaction (tx), so either both succeed
// or both are rolled back — ensuring consistency.
func createOrderInTx(ctx context.Context, tx pgx.Tx, order Order) error {
	// Step 1: Insert the business data (the actual order).
	_, err := tx.Exec(ctx,
		"INSERT INTO orders (id, product, quantity) VALUES (\(1, \)2, $3)",
		order.ID, order.Product, order.Quantity,
	)
	if err != nil {
		return err
	}
	log.Printf("Inserted order %s into database", order.ID)

	// Step 2: Serialize the event payload that consumers will receive.
	event := OrderCreatedEvent{
		OrderID: order.ID,
		Product: order.Product,
	}
	msg, err := json.Marshal(event)
	if err != nil {
		return err
	}

	// Step 3: Write the event to the outbox table.
	// This does NOT publish to Pub/Sub — it just queues it for the relay.
	_, err = tx.Exec(ctx,
		"INSERT INTO outbox (topic, message) VALUES (\(1, \)2)",
		"orders.created", msg,
	)
	if err != nil {
		return err
	}
	log.Printf("Inserted outbox event for order %s", order.ID)

	return nil
}

func main() {
	ctx := context.Background()

	pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
	if err != nil {
		log.Fatalf("Unable to connect to database: %v", err)
	}
	defer pool.Close()

	// Begin a transaction that will cover both the order insert
	// and the outbox insert.
	tx, err := pool.Begin(ctx)
	if err != nil {
		log.Fatalf("Unable to begin transaction: %v", err)
	}
	// If anything fails, the deferred Rollback is a no-op after a successful Commit.
	defer tx.Rollback(ctx)

	newOrder := Order{
		ID:       uuid.New(),
		Product:  "Super Widget",
		Quantity: 10,
	}

	if err := createOrderInTx(ctx, tx, newOrder); err != nil {
		log.Fatalf("Failed to create order: %v", err)
	}

	// Committing the transaction makes both writes permanent simultaneously.
	if err := tx.Commit(ctx); err != nil {
		log.Fatalf("Failed to commit transaction: %v", err)
	}

	log.Println("Successfully created order and queued outbox event.")
}
</code></pre>
<p>Notice that <code>createOrderInTx</code> receives a <code>pgx.Tx</code> (a transaction) rather than a pool connection. This is intentional: it enforces that the caller is responsible for managing the transaction boundary, making the atomicity guarantee explicit.</p>
<h3 id="heading-the-relay-service">The Relay Service</h3>
<p>The relay runs as a separate background process. It polls the outbox table, publishes messages, and marks them as processed.</p>
<p>A critical detail here is the use of <code>FOR UPDATE SKIP LOCKED</code> in the SQL query. This PostgreSQL feature lets you run <strong>multiple relay instances</strong> concurrently without them stepping on each other. When one instance locks a row to process it, other instances skip that row and move on to the next one.</p>
<pre><code class="language-go">// relay/main.go

package main

import (
	"context"
	"log"
	"time"

	"cloud.google.com/go/pubsub"
	"github.com/google/uuid"
	"github.com/jackc/pgx/v5/pgxpool"
)

// OutboxMessage mirrors the columns we need from the outbox table.
type OutboxMessage struct {
	ID      uuid.UUID
	Topic   string
	Message []byte
}

// processOutboxMessages picks up one pending message, publishes it to Pub/Sub,
// and marks it as processed — all within a single database transaction.
func processOutboxMessages(ctx context.Context, pool *pgxpool.Pool, pubsubClient *pubsub.Client) error {
	tx, err := pool.Begin(ctx)
	if err != nil {
		return err
	}
	defer tx.Rollback(ctx)

	// Query for the next pending message.
	// FOR UPDATE SKIP LOCKED ensures that if multiple relay instances are
	// running, they won't try to process the same message simultaneously.
	rows, err := tx.Query(ctx, `
		SELECT id, topic, message
		FROM outbox
		WHERE state = 'pending'
		ORDER BY created_at
		LIMIT 1
		FOR UPDATE SKIP LOCKED
	`)
	if err != nil {
		return err
	}
	defer rows.Close()

	var msg OutboxMessage
	if rows.Next() {
		if err := rows.Scan(&amp;msg.ID, &amp;msg.Topic, &amp;msg.Message); err != nil {
			return err
		}
	} else {
		// No pending messages — nothing to do.
		return nil
	}

	log.Printf("Publishing message %s to topic %s", msg.ID, msg.Topic)

	// Publish the message to the Pub/Sub topic and wait for confirmation.
	result := pubsubClient.Topic(msg.Topic).Publish(ctx, &amp;pubsub.Message{
		Data: msg.Message,
	})
	if _, err = result.Get(ctx); err != nil {
		// Publishing failed. We return the error here without committing,
		// so the transaction rolls back and the message stays 'pending'.
		// The relay will retry it on the next polling interval.
		return err
	}

	// Mark the message as processed now that the broker has confirmed receipt.
	_, err = tx.Exec(ctx,
		"UPDATE outbox SET state = 'processed', processed_at = now() WHERE id = $1",
		msg.ID,
	)
	if err != nil {
		return err
	}
	log.Printf("Marked message %s as processed", msg.ID)

	// Commit the transaction: the state update becomes permanent.
	return tx.Commit(ctx)
}

func main() {
	// In production, initialize real connections using environment variables
	// or a config file. These are left as placeholders for clarity.
	var (
		pool         *pgxpool.Pool
		pubsubClient *pubsub.Client
	)

	// Poll the outbox table every second.
	// Adjust the interval based on your latency requirements.
	ticker := time.NewTicker(1 * time.Second)
	defer ticker.Stop()

	for range ticker.C {
		if err := processOutboxMessages(context.Background(), pool, pubsubClient); err != nil {
			log.Printf("Error processing outbox: %v", err)
		}
	}
}
</code></pre>
<p>The polling interval (1 second in this example) controls the maximum latency between an event being written to the outbox and it being published to the broker. For most use cases, 1–5 seconds is perfectly acceptable. If you need lower latency, you can reduce the interval, or consider using PostgreSQL's <code>LISTEN/NOTIFY</code> feature to wake up the relay immediately when a new row is inserted.</p>
<h2 id="heading-why-messages-can-be-delivered-more-than-once">Why Messages Can Be Delivered More Than Once</h2>
<p>You might wonder: isn't the Outbox Pattern supposed to guarantee <em>exactly once</em> delivery?</p>
<p>It does not. It guarantees <strong>at-least-once</strong> delivery. Here's the edge case:</p>
<ol>
<li><p>The relay publishes the message to Pub/Sub successfully.</p>
</li>
<li><p>Before it can update the outbox row to <code>'processed'</code>, the relay process crashes.</p>
</li>
<li><p>On restart, the relay sees the message is still <code>'pending'</code> and publishes it again.</p>
</li>
</ol>
<p>This is a rare but possible scenario. The standard way to handle it is to design your message <strong>consumers to be idempotent</strong>. This means that they can safely receive and process the same message multiple times without causing incorrect behavior.</p>
<p>Common strategies for idempotency include:</p>
<ul>
<li><p>Using the message's <code>id</code> as a deduplication key, and checking if you've already processed it before acting.</p>
</li>
<li><p>Making your operations naturally idempotent. For example, using <code>INSERT ... ON CONFLICT DO NOTHING</code> instead of a plain <code>INSERT</code>.</p>
</li>
</ul>
<h2 id="heading-alternative-postgresql-logical-replication">Alternative: PostgreSQL Logical Replication</h2>
<p>The polling approach described above is simple and works well, but it has two drawbacks: it introduces some latency (up to one polling interval), and it issues database queries even when there's nothing to process.</p>
<p>For high-throughput systems where these trade-offs matter, PostgreSQL offers a more advanced alternative: <strong>logical replication</strong> via the <strong>Write-Ahead Log (WAL)</strong>.</p>
<p>Every change made to a PostgreSQL database is first written to the WAL – an append-only log used for crash recovery and replication. With logical replication, you can subscribe to changes in specific tables and receive them as a stream in near real-time.</p>
<p>Instead of your relay asking "Are there any new messages?" on a schedule, PostgreSQL will proactively notify your relay the moment a new row is inserted into the outbox table.</p>
<p>This approach is lower latency and more resource-efficient for high-volume workloads. The trade-off is added implementation complexity: you need to manage a replication slot in PostgreSQL and handle the WAL stream correctly.</p>
<p>In Go, you can use the <a href="https://github.com/jackc/pglogrepl">pglogrepl</a> library to interact with PostgreSQL's logical replication protocol.</p>
<p>For more details on how WAL and change data capture work in PostgreSQL, see the <a href="https://www.postgresql.org/docs/current/wal-intro.html">official Write-Ahead Logging documentation</a>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5ea89c91fdc930d846b413ab/c706cc8f-6bbd-49d1-90c0-8c4934c2718e.png" alt="diagram with WAL" style="display:block;margin:0 auto" width="2362" height="1224" loading="lazy">

<h2 id="heading-conclusion">Conclusion</h2>
<p>The Outbox Pattern solves a fundamental problem in distributed systems: how do you reliably perform a database write and publish a message to a broker in a consistent way?</p>
<p>The key idea is to use your database as the source of truth for <em>both</em> the business data and the pending messages. By writing to the outbox table in the same transaction as your business data, you get atomic guarantees from the database itself: no distributed transaction protocol required.</p>
<p>Here's a quick summary of the key concepts:</p>
<ul>
<li><p><strong>The outbox table</strong> stores pending events as part of your regular database schema.</p>
</li>
<li><p><strong>The transaction</strong> wraps both the business write and the outbox write, making them atomic.</p>
</li>
<li><p><strong>The Message Relay</strong> is a background process that reads from the outbox and publishes to the broker.</p>
</li>
<li><p><strong>At-least-once delivery</strong> means your consumers must be idempotent.</p>
</li>
<li><p><code>FOR UPDATE SKIP LOCKED</code> allows multiple relay instances to run safely in parallel.</p>
</li>
<li><p><strong>Logical replication</strong> is an advanced alternative that avoids polling for high-throughput systems.</p>
</li>
</ul>
<p>The pattern is simple in concept, but there are several ways to implement it depending on your scale and infrastructure. The polling approach shown in this tutorial is a solid starting point for most applications.</p>
<h3 id="heading-resources">Resources</h3>
<ul>
<li><p><a href="https://github.com/plutov/packagemain/tree/master/outbox">Source code on GitHub</a></p>
</li>
<li><p><a href="https://www.postgresql.org/docs/current/wal-intro.html">PostgreSQL Write-Ahead Logging (WAL)</a></p>
</li>
<li><p><a href="https://github.com/jackc/pglogrepl">pglogrepl – Go library for PostgreSQL logical replication</a></p>
</li>
<li><p><a href="https://github.com/jackc/pgx">pgx – PostgreSQL driver and toolkit for Go</a></p>
</li>
<li><p><a href="https://packagemain.tech">Explore more Go tutorials on packagemain.tech</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ What is Disaster Recovery Testing? Explained with Practical Examples ]]>
                </title>
                <description>
                    <![CDATA[ Most teams are confident they can recover from a major outage until they actually have to. Backups exist, architectures are redundant and a recovery plan is documented somewhere, yet real incidents of ]]>
                </description>
                <link>https://www.freecodecamp.org/news/disaster-recovery-testing/</link>
                <guid isPermaLink="false">69a5614ffc6453a5f17ca809</guid>
                
                    <category>
                        <![CDATA[ Testing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cybersecurity ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Databases ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Alex Tray ]]>
                </dc:creator>
                <pubDate>Mon, 02 Mar 2026 10:07:11 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/57c1e51b-867c-444e-90f0-e6551284fe0a.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most teams are confident they can recover from a major outage until they actually have to. Backups exist, architectures are redundant and a recovery plan is documented somewhere, yet real incidents often reveal critical gaps.</p>
<p>Disaster recovery testing is what separates assumed resilience from proven recovery, but it’s still skipped, rushed or treated as a checkbox exercise. For developers and technical teams, that gap can turn a manageable failure into a prolonged outage.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-disaster-recovery-testing">What is Disaster Recovery Testing?</a></p>
</li>
<li><p><a href="#heading-how-disaster-recovery-testing-works-in-practice">How Disaster Recovery Testing Works in Practice</a></p>
</li>
<li><p><a href="#heading-disaster-recovery-testing-methods-developers-should-know">Disaster Recovery Testing Methods Developers Should Know</a></p>
</li>
<li><p><a href="#heading-what-technology-disaster-recovery-testing-evaluates">What Technology Disaster Recovery Testing Evaluates</a></p>
</li>
<li><p><a href="#heading-how-to-test-a-disaster-recovery-plan">How to Test a Disaster Recovery Plan</a></p>
</li>
<li><p><a href="#heading-disaster-recovery-test-scenarios-practical-examples">Disaster Recovery Test Scenarios: Practical Examples</a></p>
</li>
<li><p><a href="#heading-disaster-recovery-test-report-turning-tests-into-improvements">Disaster Recovery Test Report: Turning Tests Into Improvements</a></p>
</li>
<li><p><a href="#heading-disaster-recovery-audits-and-continuous-validation">Disaster Recovery Audits and Continuous Validation</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-is-disaster-recovery-testing"><strong>What is Disaster Recovery Testing?</strong></h2>
<p>Disaster recovery (DR) testing is the process of validating that systems, data and applications can be restored after a disruptive event within defined recovery objectives. It generally evaluates:</p>
<ul>
<li><p><strong>Recovery Time Objective (RTO)</strong>: How quickly systems must be restored.</p>
</li>
<li><p><strong>Recovery Point Objective (RPO)</strong>: How much data loss is acceptable.</p>
</li>
<li><p><strong>Operational readiness</strong>: Whether teams know what to do during an incident.</p>
</li>
</ul>
<p>A disaster recovery test plan documents how these elements are tested, who is responsible and what success looks like. Without testing, DR plans are assumptions, not guarantees.</p>
<h2 id="heading-how-disaster-recovery-testing-works-in-practice"><strong>How Disaster Recovery Testing Works in Practice</strong></h2>
<p>In real environments, disaster recovery testing is used to check all <a href="https://www.nakivo.com/blog/components-disaster-recovery-plan-checklist/">elements of the disaster recovery plan</a> and is rarely a single event. It’s a structured exercise that simulates failure, observes system behavior and measures outcomes against expectations.</p>
<p>A typical DR test involves:</p>
<ol>
<li><p><strong>Defining scope</strong> – Which applications, services, or data sets are included.</p>
</li>
<li><p><strong>Selecting a scenario</strong> – Outage, corruption, ransomware, region failure, and so on.</p>
</li>
<li><p><strong>Executing recovery actions</strong> – Restore data, fail over systems, reconfigure dependencies.</p>
</li>
<li><p><strong>Measuring results</strong> – Time to recovery, data consistency, service availability.</p>
</li>
<li><p><strong>Documenting findings</strong> – What worked, what failed, what needs improvement.</p>
</li>
</ol>
<p>For developers, the key shift is recognizing that DR testing isn’t just an ops exercise. Application architecture, data handling and deployment patterns all influence recovery outcomes.</p>
<p>Importantly, regulatory pressure is also reshaping how organizations approach recovery validation. Frameworks such as the <a href="https://heimdalsecurity.com/nis-2-directive">NIS2 Directive</a> require essential and important entities in the EU to implement robust cybersecurity risk management measures, including incident response and business continuity capabilities.</p>
<h2 id="heading-disaster-recovery-testing-methods-developers-should-know"><strong>Disaster Recovery Testing Methods Developers Should Know</strong></h2>
<p>Different testing methods provide different levels of confidence. Mature teams use more than one. Each method has a place, but relying only on low-impact testing creates blind spots that surface during real incidents.</p>
<h3 id="heading-checklist-testing"><strong>Checklist Testing</strong></h3>
<p>The simplest method: Teams review documented recovery steps without executing them. This helps validate documentation completeness but does not confirm real-world recoverability.</p>
<h3 id="heading-tabletop-exercises"><strong>Tabletop Exercises</strong></h3>
<p>Stakeholders walk through a simulated disaster scenario and discuss responses. Tabletop tests are useful for identifying communication gaps and unclear responsibilities, especially for cross-team coordination.</p>
<h3 id="heading-partial-or-component-testing"><strong>Partial or Component Testing</strong></h3>
<p>Specific systems, such as databases or backup restores, are tested in isolation. Developers often encounter this when validating recovery procedures for individual services or environments.</p>
<h3 id="heading-full-scale-testing"><strong>Full-scale Testing</strong></h3>
<p>This is the most comprehensive method. It involves actual failover or full recovery in production-like environments. While disruptive, full-scale tests provide the highest confidence.</p>
<h2 id="heading-what-technology-disaster-recovery-testing-evaluates"><strong>What Technology Disaster Recovery Testing Evaluates</strong></h2>
<p>Modern environments are complex, and disaster recovery testing must validate more than just data restores.</p>
<p>DR testing evaluates:</p>
<ul>
<li><p><strong>Backup integrity</strong> – Are backups usable, consistent and complete?</p>
</li>
<li><p><strong>Application dependencies</strong> – Do services come back in the correct order?</p>
</li>
<li><p><strong>Infrastructure recovery</strong> – Can compute, storage and networking be re-provisioned?</p>
</li>
<li><p><strong>Identity and access</strong> – Do credentials, secrets and permissions still function?</p>
</li>
<li><p><strong>Automation and scripts</strong> – Do recovery workflows still match current architectures?</p>
</li>
</ul>
<p>For developers, this often reveals hidden coupling between services, outdated scripts or environment-specific assumptions that were never documented.</p>
<h2 id="heading-how-to-test-a-disaster-recovery-plan"><strong>How to Test a Disaster Recovery Plan</strong></h2>
<p>Testing a disaster recovery plan doesn’t require shutting down production on day one. A practical, incremental approach works best.</p>
<ol>
<li><p><strong>Start with a single application</strong>: Pick a service with well-defined data and dependencies. Avoid starting with your most complex system.</p>
</li>
<li><p><strong>Validate backup restores</strong>: Restore data into a non-production environment and confirm application functionality, not just file presence.</p>
</li>
<li><p><strong>Measure RTO and RPO</strong>: Time the recovery process and compare results to stated objectives. At this stage, many teams can discover that their objectives were unrealistic.</p>
</li>
<li><p><strong>Test failure assumptions</strong>: Simulate real-world issues like missing credentials, expired certificates or partial data loss.</p>
</li>
<li><p><strong>Document gaps immediately</strong>: Update the disaster recovery test plan while findings are fresh. Untested fixes are just new assumptions.</p>
</li>
</ol>
<p>This approach makes disaster recovery testing part of standard processes rather than a once-a-year compliance task.</p>
<h3 id="heading-automating-restore-validation"><strong>Automating Restore Validation</strong></h3>
<p>One of the most common gaps in disaster recovery testing is stopping at “restore completed” instead of validating that the application actually works. A restored database that can’t serve queries or contains incomplete data doesn’t meet recovery objectives.</p>
<p>Teams can reduce this risk by automating post-restore validation. For example, after restoring a PostgreSQL database into a staging or isolated DR environment, a simple validation script can confirm connectivity and basic data integrity:</p>
<pre><code class="language-python">import psycopg2

import sys


def validate_restore():

&nbsp;&nbsp;&nbsp;&nbsp;try:

&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;conn = psycopg2.connect(

&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;host="restored-db.internal",

&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;database="appdb",

&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;user="dr_test_user",

&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;password="securepassword"

&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;)

&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;cur = conn.cursor()

&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;cur.execute("SELECT COUNT(*) FROM users;")

&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;result = cur.fetchone()



&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;if result and result[0] &gt; 0:

&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;print("Restore validation successful.")

&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;else:

&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;print("Restore validation failed: No data found.")

&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;sys.exit(1)


&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;conn.close()

&nbsp;&nbsp;&nbsp;&nbsp;except Exception as e:

&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;print(f"Restore validation error: {e}")

&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;sys.exit(1)


validate_restore()
</code></pre>
<p>This script does three important things:</p>
<ul>
<li><p>Confirms the database is reachable</p>
</li>
<li><p>Executes a real query, not just a connection check</p>
</li>
<li><p>Fails explicitly if the expected data is missing</p>
</li>
</ul>
<p>In practice, teams can integrate scripts like this into CI/CD pipelines or scheduled recovery drills. The goal isn’t to test every edge case, but to move from “backup exists” to “restore is functionally verified.” Over time, these automated checks become part of the disaster recovery test plan, helping teams measure RTO accurately and detect configuration drift before a real incident exposes it.</p>
<h2 id="heading-disaster-recovery-test-scenarios-practical-examples"><strong>Disaster Recovery Test Scenarios: Practical Examples</strong></h2>
<p>Effective disaster recovery testing focuses on realistic failures, not idealized outages.</p>
<h3 id="heading-accidental-deletion-or-misconfiguration"><strong>Accidental Deletion or Misconfiguration</strong></h3>
<p>A dropped database table, deleted storage bucket or bad configuration change tests how quickly teams can restore specific data without rolling back entire systems. These everyday incidents often reveal slow or overly manual recovery processes.</p>
<h3 id="heading-data-corruption-and-application-failure"><strong>Data Corruption and Application Failure</strong></h3>
<p>Buggy releases can silently corrupt data while systems remain online. This scenario validates point-in-time recovery and whether teams can identify when corruption started, not just restore the latest backup.</p>
<h3 id="heading-ransomware-simulation"><strong>Ransomware Simulation</strong></h3>
<p>Ransomware testing checks whether clean, uncompromised backups can be restored in isolation. It often exposes gaps in backup immutability, credential handling and realistic recovery times.</p>
<h3 id="heading-infrastructure-or-platform-outage"><strong>Infrastructure or Platform Outage</strong></h3>
<p>Simulating the loss of a cluster, availability zone or region tests automation and infrastructure-as-code maturity. In virtualized environments, most commonly <a href="https://www.nakivo.com/vmware-disaster-recovery/">VMware disaster recovery</a>, testing involves restoring virtual machines at a secondary site and validating networking and application dependencies.</p>
<h3 id="heading-credential-and-access-failure"><strong>Credential and Access Failure</strong></h3>
<p>Recovery can stall if credentials, certificates or secret keys are unavailable. Testing this scenario validates identity systems and whether recovery procedures rely on fragile access assumptions.</p>
<h2 id="heading-disaster-recovery-test-report-turning-tests-into-improvements"><strong>Disaster Recovery Test Report: Turning Tests Into Improvements</strong></h2>
<p>Testing without documentation is wasted effort. A disaster recovery test report turns results into actionable improvements.</p>
<p>A valuable DR test report includes:</p>
<ul>
<li><p>Test scope and scenario</p>
</li>
<li><p>Expected vs. actual RTO/RPO</p>
</li>
<li><p>Recovery steps executed</p>
</li>
<li><p>Failures, delays and root causes</p>
</li>
<li><p>Recommended changes</p>
</li>
</ul>
<p>For developers, this often results in concrete action items: refactoring startup dependencies, adding health checks, improving automation or adjusting data protection policies. The report should feed directly into backlog planning.</p>
<h2 id="heading-disaster-recovery-audits-and-continuous-validation"><strong>Disaster Recovery Audits and Continuous Validation</strong></h2>
<p>Audits often expose what teams already suspect: Disaster recovery plans exist, but haven’t been tested recently (or at all).</p>
<p>Rather than treating audits as one-time events, teams should adopt continuous validation:</p>
<ul>
<li><p>Regular restore tests integrated into CI/CD pipelines.</p>
</li>
<li><p>Scheduled DR tests tied to major architecture changes.</p>
</li>
<li><p>Automated alerts when recovery objectives drift.</p>
</li>
</ul>
<p>This shifts disaster recovery testing from an annual obligation to an ongoing practice that evolves alongside the environment.</p>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>Disaster recovery testing is not about pessimism, it’s about realism. Systems and people change, and failure modes evolve faster than documentation. Without testing, even the best-designed recovery plan can become outdated.</p>
<p>For developers and technical teams, practicing disaster recovery testing builds confidence rooted in evidence, not assumptions. It exposes hidden dependencies, validates data protection strategies and ensures that when something goes wrong, recovery is predictable instead of chaotic.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Elevate Your Database Game: Supercharging Query Performance with Postgres FDW ]]>
                </title>
                <description>
                    <![CDATA[ Foreign data wrappers (FDWs) make remote Postgres tables feel local. That convenience is exactly why FDW performance surprises are so common. A query that looks like a normal join can execute like a distributed system: rows move across the network, r... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/fdw-pushdown/</link>
                <guid isPermaLink="false">69963f00d35b661838993bd0</guid>
                
                    <category>
                        <![CDATA[ performance ]]>
                    </category>
                
                    <category>
                        <![CDATA[ PostgreSQL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Databases ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Hamdaan Ali ]]>
                </dc:creator>
                <pubDate>Wed, 18 Feb 2026 22:36:48 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1771357398917/8db8c3fd-9f16-4631-aa48-2537e8a4cb45.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Foreign data wrappers (FDWs) make remote Postgres tables feel local. That convenience is exactly why FDW performance surprises are so common.</p>
<p>A query that looks like a normal join can execute like a distributed system: rows move across the network, remote statements get executed repeatedly, and the local planner quietly becomes a coordinator. In that world, “fast SQL” is not mainly about CPU or indexes. It’s about <strong>data movement</strong> and <strong>round-trips</strong>.</p>
<p>This handbook covers the mechanism that determines whether a federated query behaves like a clean remote query or a chatty distributed workflow: <strong>pushdown</strong>.</p>
<p>Pushdown is not “moving compute”. Pushdown determines whether filtering, joining, ordering, and aggregation occur at the data source or after the data has already crossed the wire. When pushdown works, the local server receives a reduced result set. When it doesn’t, Postgres often has to fetch broad intermediate sets and finish the work locally.</p>
<p>The chapters ahead will help you build a practical mental model of what is “shippable” in <code>postgres_fdw</code>, why some expressions are blocked, and how to read <code>EXPLAIN (ANALYZE, BUFFERS, VERBOSE)</code> without getting tricked by familiar plan shapes.</p>
<p>After the core method, the handbook covers tuning knobs that matter in production, schema and indexing considerations, benchmarking methodology, monitoring and logging, and a case study that shows what a real pushdown win looks like end-to-end.</p>
<p>The later sections go deeper into advanced shippability edge cases, cost model calibration, and regression-proofing FDW workloads.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-executive-summary">Executive Summary</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-motivation">Motivation</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-fdw-basics-without-the-setup-tax">FDW Basics Without the Setup Tax</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-pushdown-mechanics">Pushdown Mechanics</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-shippable-operations-a-deep-dive">Shippable Operations: a Deep Dive</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-pushdown-blockers-and-why-they-exist">Pushdown Blockers and Why They Exist</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-reading-explain-like-a-pro">Reading EXPLAIN Like a Pro</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-tune-postgresfdw">How to Tune postgres_fdw</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-schema-and-index-recommendations">Schema and Index Recommendations</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-benchmarking-methodology">Benchmarking Methodology</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-monitoring-and-logging">Monitoring and Logging</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-case-study-refactoring-a-keycloak-coverage-query">Case Study: Refactoring a Keycloak Coverage Query</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-checklist-and-troubleshooting-guide">Checklist and Troubleshooting Guide</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-case-study-takeaways">Case Study Takeaways</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-advanced-operations-a-deeper-dive-into-shippability">Advanced Operations: A Deeper Dive into Shippability</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-common-antipatterns-and-how-to-avoid-them">Common Anti‑Patterns and How to Avoid Them</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-extending-tuning-calibrating-cost-models">Extending Tuning: Calibrating Cost Models</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-further-case-studies-and-practical-examples">Further Case Studies and Practical Examples</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-monitoring-diagnostics-and-regression-testing">Monitoring, Diagnostics, and Regression Testing</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-extended-guidelines-for-advanced-dbas">Extended Guidelines for Advanced DBAs</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-bringing-it-all-together">Bringing it All Together</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-references">References</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This handbook assumes basic comfort with Postgres query plans. It builds on <code>EXPLAIN (ANALYZE, BUFFERS)</code> rather than reintroducing SQL fundamentals, indexing, or join algorithms.</p>
<p>The focus here is federated execution: how foreign queries behave, and how to reason about them with the same clarity as local plans.</p>
<p>Here’s what you should already be comfortable with:</p>
<ul>
<li><p>Reading <code>EXPLAIN (ANALYZE, BUFFERS)</code> output and spotting obvious plan smells (row explosions, bad join order, missed indexes).</p>
</li>
<li><p>Basic join mechanics (nested loop, hash join, merge join) and why cardinality estimates matter.</p>
</li>
<li><p>Postgres statistics at a practical level (<code>ANALYZE</code>, correlation, and what “estimated rows vs actual rows” implies).</p>
</li>
</ul>
<p>And here’s what you need to follow along with the examples:</p>
<ul>
<li><p>A Postgres “local” instance that will run <code>postgres_fdw</code> and act as the coordinator.</p>
</li>
<li><p>A Postgres “remote” instance that holds the foreign tables.</p>
</li>
<li><p>Permission on the local side to:</p>
<ul>
<li><p><code>CREATE EXTENSION postgres_fdw;</code></p>
</li>
<li><p>create a <code>SERVER</code> and <code>USER MAPPING</code></p>
</li>
<li><p>create <code>FOREIGN TABLE</code> objects (or permission to use existing ones)</p>
</li>
</ul>
</li>
<li><p>A way to run queries and capture plans:</p>
<ul>
<li><code>psql</code> is enough, and so is any GUI, as long as you can run <code>EXPLAIN (ANALYZE, BUFFERS, VERBOSE)</code>.</li>
</ul>
</li>
</ul>
<p>We won’t go through a long environment setup walkthrough. The examples assume the FDW objects exist and focus on plans and behavior.</p>
<p>We also won’t go into general distributed systems theory. Only the pieces that show up in an FDW plan are used.</p>
<h2 id="heading-executive-summary">Executive Summary</h2>
<p>The single most important lesson of this handbook is that <strong>FDW pushdown reduces data movement</strong>. It’s tempting to think of pushdown as merely changing where a calculation happens (“move the work to the remote”). But what really matters is whether the remote server is asked for only the rows you need.</p>
<p>When pushdown is working, the remote server performs the selective join and filtering, and the local Postgres receives a small, already reduced result set. When pushdown fails, the local server becomes a distributed query coordinator: it pulls large intermediate sets over the network and then finishes the heavy lifting locally.</p>
<p>Why does this matter? Because a refactor that makes more of your query shippable to the remote server can slash end‑to‑end latency without changing a single row of output. In the case study we'll explore later, rewriting a query so that the FDW can ship a joined remote query instead of performing multiple foreign scans and local joins reduces runtime from approximately <strong>166 ms to 25 ms</strong>. The business logic did not change – the <em>shape</em> of the work changed.</p>
<p>Below is a simple bar chart illustrating that dramatic drop. The chart uses actual timings from the case study. If you run the experiment yourself, the numbers may differ depending on your hardware and network, but the relative difference should be clear.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771117284661/ecadfc8b-7e45-4122-921d-5b06215d627a.png" alt="Bar chart titled &quot;Query Execution Time: Before vs After Refactor.&quot; The chart shows execution time in milliseconds on the vertical axis. The &quot;Before&quot; bar is much taller, over 160 ms, compared to the &quot;After&quot; bar, which is below 20 ms, indicating a significant improvement in execution time after refactoring." class="image--center mx-auto" width="840" height="630" loading="lazy"></p>
<h2 id="heading-motivation">Motivation</h2>
<p>Foreign data wrappers let you query remote data using the same SQL syntax you use locally. That convenience is exactly why they can be so deceptive.</p>
<p>A federated query may look like a normal join, but under the hood, it behaves like a distributed system: some part of the plan runs on the remote server, some on the local server, and every boundary between them is a network hop. The slow path is rarely “bad SQL” – it’s usually a combination of two things:</p>
<ol>
<li><p><strong>Too many rows are pulled over the network.</strong> Without pushdown, the FDW retrieves a large slice of the remote table and applies your filters and joins locally. This may lead to tens of thousands or millions of rows being shipped across the network when you only needed hundreds or fewer.</p>
</li>
<li><p><strong>Too many round-trips.</strong> If the plan performs a nested loop that drives a foreign scan, it can end up executing the same remote query hundreds or thousands of times. Each call might be fast on its own, but latency adds up.</p>
</li>
</ol>
<p>This isn't speculation. PostgreSQL's documentation makes clear that a foreign table <strong>has no local storage</strong> and that Postgres “asks the FDW to fetch data from the external source” <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=It%20is%20generally%20recommended%20that,differently%20from%20the%20local%20server">[1]</a>. There is no local buffer cache or heap storage to hide mistakes. Every row you retrieve must traverse the network at least once. If your plan fetches more rows than it needs, or repeatedly does so, performance can degrade quickly.</p>
<p>That’s why you should treat the Remote SQL shown in <code>EXPLAIN (VERBOSE)</code> as part of your query plan. It tells you exactly what the remote server is being asked to do. If it’s missing your filters or joins, you know the local server will have to finish the job. The rest of this handbook will teach you how to read that plan, how to force pushdown when possible, and how to recognize the signs that something has gone wrong.</p>
<h2 id="heading-fdw-basics-without-the-setup-tax">FDW Basics Without the Setup Tax</h2>
<p>You might be tempted to skip this section if you've already created foreign tables in your own databases. Don't. Understanding the architecture of foreign data wrappers is essential to understanding why pushdown matters.</p>
<h3 id="heading-sqlmed-in-a-nutshell">SQL/MED in a nutshell</h3>
<p>PostgreSQL implements the <strong>SQL/MED</strong> (Management of External Data) standard through its FDW framework. To access a remote Postgres server via <code>postgres_fdw</code>, you perform four steps:</p>
<ol>
<li><p><strong>Install the extension</strong>: <code>CREATE EXTENSION postgres_fdw</code> tells Postgres to load the FDW code.</p>
</li>
<li><p><strong>Create a foreign server</strong>: <code>CREATE SERVER foreign_server FOREIGN DATA WRAPPER postgres_fdw OPTIONS (host '...', port '...', dbname '...')</code>defines where the remote server resides and how to connect.</p>
</li>
<li><p><strong>Create a user mapping</strong>: <code>CREATE USER MAPPING FOR your_user SERVER foreign_server OPTIONS (user 'remote_user', password '...')</code> tells Postgres how to authenticate on the remote side.</p>
</li>
<li><p><strong>Create a foreign table</strong>: <code>CREATE FOREIGN TABLE remote_table (...) SERVER foreign_server OPTIONS (schema_name '...', table_name '...');</code> defines the columns and references the remote table.</p>
</li>
</ol>
<p>Once you've done that, you can run <code>SELECT</code> statements against the foreign table as if it were local. But the definition hides an important detail: there is no storage associated with that foreign table <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=It%20is%20generally%20recommended%20that,differently%20from%20the%20local%20server">[1]</a>. Every time you <code>SELECT</code>, <code>INSERT</code>, <code>UPDATE</code>, or <code>DELETE</code>, the FDW must connect to the remote server, build a remote query, send it, and read the results. This overhead is small for simple queries but becomes critical as queries get more complex.</p>
<h3 id="heading-what-postgresfdw-does-and-does-not-do">What postgres_fdw does and does not do</h3>
<p><code>postgres_fdw</code> does two things for you:</p>
<ol>
<li><p>It builds remote SQL from your query, including pushing down safe filters, joins, sorts, and aggregates when it can.</p>
</li>
<li><p>It fetches rows from the remote server and hands them to the local executor. If some part of your query cannot be executed remotely, the local executor performs that part.</p>
</li>
</ol>
<p>The FDW tries hard to minimize data transfer by sending as much of your <code>WHERE</code> clause as possible to the remote server and by not retrieving unused columns <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=">[2]</a>. It also has a number of tuning knobs that we'll explore later (such as <code>fetch_size</code>, <code>use_remote_estimate</code>, <code>fdw_startup_cost</code>, and <code>fdw_tuple_cost</code><a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=This%20option%2C%20which%20can%20be,false">[3]</a>). But the real win often comes from structuring your query so that the FDW can push work down.</p>
<p>There's one last architectural point to keep in mind: the remote server runs with a restricted session environment. In remote sessions opened by <code>postgres_fdw</code>, the <code>search_path</code> is set to <code>pg_catalog</code> only, and <code>TimeZone</code>, <code>DateStyle</code>, and <code>IntervalStyle</code> are set to specific values <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=In%20the%20remote%20sessions%20opened,their%20expected%20search%20path%20environment">[4]</a>. This means that any functions you expect to run remotely must be schema‑qualified or packaged in a way that the FDW can find them. It also underscores why you should not override session settings for FDW connections unless you know exactly what you are doing <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=In%20the%20remote%20sessions%20opened,their%20expected%20search%20path%20environment">[4]</a>.</p>
<h2 id="heading-pushdown-mechanics">Pushdown Mechanics</h2>
<p>At a high level, “pushdown” means pushing as much of your SQL query as possible to the remote server. But the FDW cannot simply send arbitrary SQL. It must be <em>safe</em> and <em>portable</em> for remote evaluation. Postgres uses the term <strong>shippable</strong> to describe expressions and operations that can be evaluated on the foreign server.</p>
<h3 id="heading-what-shippable-means-in-practice">What “shippable” means in practice</h3>
<p>An expression is considered shippable if it meets several conditions:</p>
<ol>
<li><p><strong>It uses built‑in functions, operators, or data types</strong>, or functions/operators from extensions that have been explicitly allow‑listed via the extensions option on the foreign server <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=">[2]</a>. If you use a custom function or an extension that has not been declared, the FDW assumes it cannot run remotely.</p>
</li>
<li><p><strong>It’s marked IMMUTABLE.</strong> Postgres distinguishes between <code>IMMUTABLE</code>, <code>STABLE</code>, and <code>VOLATILE</code> functions. Only immutable functions – those that always return the same output for the same inputs and don’t depend on session state – are candidates for pushdown <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=functions%20in%20such%20clauses%20must,to%20reduce%20the%20risk%20of">[5]</a>. This rule prevents time‑dependent functions, such as <code>now()</code> or <code>random()</code> from being evaluated remotely, because the result might differ between the local and remote servers.</p>
</li>
<li><p><strong>It doesn’t depend on local collations or type conversions</strong>. PostgreSQL’s docs warn that type or collation mismatches can lead to semantic anomalies <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=It%20is%20generally%20recommended%20that,differently%20from%20the%20local%20server">[1]</a>. If the FDW cannot guarantee that a comparison behaves identically on both servers, it will refuse to push it down. For example, comparing a <code>citext</code> column to a <code>text</code> constant could be unsafe if the remote server doesn’t have the <code>citext</code> extension installed.</p>
</li>
</ol>
<p>From these rules, you can derive a mental checklist: avoid non‑immutable functions in your <code>WHERE</code> clause, keep your join conditions simple and typed correctly, and list any third‑party extensions you want to use in the foreign server’s extensions option so that they are considered shippable <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=">[2]</a>.</p>
<h3 id="heading-where-pushdown">WHERE pushdown</h3>
<p>If a <code>WHERE</code> clause consists entirely of shippable expressions, it will be included in the remote query. Otherwise, it will be evaluated locally. This matters because pushing a filter down reduces the number of rows returned to the local server.</p>
<p>Consider a predicate like this:</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">WHERE</span> created_at &gt;= now() - <span class="hljs-type">interval</span> <span class="hljs-string">'30 days'</span>
</code></pre>
<p>Because <code>now()</code> is volatile (it returns a different value each time it’s called), Postgres cannot assume the remote server will interpret <code>now()</code> the same way. The FDW therefore pulls the entire table and applies the filter locally.</p>
<p>A better approach is to pass a parameter into the query or compute the cutoff timestamp once in the application and embed it into the SQL.</p>
<h3 id="heading-join-pushdown-conditions">Join pushdown conditions</h3>
<p>Joins are the next big lever. When <code>postgres_fdw</code> encounters a join between foreign tables on the <strong>same foreign server</strong>, it will send the entire join to the remote server unless it believes it will be more efficient to fetch the tables individually or unless the tables use different user mappings <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=When%20,clauses">[6]</a>.</p>
<p>It applies the same precautions described for <code>WHERE</code> clauses: the join condition must be shippable, and both tables must be on the same server. Cross‑server joins are never pushed down – the FDW will perform them locally.</p>
<h3 id="heading-shippability-decision-tree">Shippability decision tree</h3>
<p>It can be helpful to visualize the shippability rules as a flowchart. Below is a simple decision tree that you can use when inspecting an expression or join clause.</p>
<p>It starts with the question of whether an expression is in a WHERE or JOIN clause. Further decisions are made based on factors like using volatile functions, built-in functions, type mismatches, or cross-server joins. The flowchart concludes with outcomes like "Not shippable, evaluated locally" or "Shippable, included in Remote SQL."</p>
<p>If you reach the left side of the tree, the expression will be evaluated locally. If you reach the right side, the FDW can ship it.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771109842865/9dafcd32-c390-487d-8b35-2911d6075b13.png" alt="Flowchart for determining SQL expression shippability. It starts with the question of whether an expression is in a WHERE or JOIN clause. Further decisions are made based on factors like using volatile functions, built-in functions, type mismatches, or cross-server joins. The flowchart concludes with outcomes like &quot;Not shippable, evaluated locally&quot; or &quot;Shippable, included in Remote SQL.&quot;" class="image--center mx-auto" width="8192" height="2404" loading="lazy"></p>
<h2 id="heading-shippable-operations-a-deep-dive">Shippable Operations: a Deep Dive</h2>
<p>Postgres has been expanding what <code>postgres_fdw</code> can be pushed down over several versions. This section walks through each operation class and the conditions required for pushdown.</p>
<h3 id="heading-filters-where-clauses">Filters (WHERE clauses)</h3>
<p>As explained above, simple filters that use built‑in operators and immutable functions are generally pushed down. If you see a <code>Filter:</code> node above a Foreign Scan in your plan, it means some part of your predicate didn’t qualify. Common reasons include using <code>now()</code>, <code>timezone()</code> or other volatile functions, referencing a non‑allow‑listed extension, or comparing different collation settings.</p>
<p>When this happens, the entire table (or at least all rows matching other shippable conditions) is fetched, and the filter is applied locally.</p>
<p><strong>Plan smell:</strong> Look for a Foreign Scan node with a <code>Filter:</code> line directly above it. That means filtering happened locally. Also look for broad Remote SQL such as:</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">SELECT</span> * <span class="hljs-keyword">FROM</span> remote_table <span class="hljs-keyword">WHERE</span> (<span class="hljs-type">name</span> = <span class="hljs-string">'Hamdaan'</span>)
</code></pre>
<p>with no group constraints. That's a sign that the filter was not pushed down.</p>
<h3 id="heading-joins">Joins</h3>
<p>Simple inner joins between foreign tables on the same foreign server are usually pushable. The join condition must satisfy the same shippability rules as filters. If the join involves more than one foreign server, if the join condition uses an unshippable function, or if the foreign tables use different user mappings, the FDW will fetch each table separately and join them locally <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=When%20,clauses">[6]</a>. This can lead to large intermediate sets being transferred.</p>
<p><strong>Plan smell:</strong> A Hash Join or Merge Join where both inputs are Foreign Scan nodes indicates that the join was performed locally. Conversely, a single Foreign Scan representing a join and containing the <code>JOIN ... ON</code> clause in Remote SQL indicates that the join was pushed down.</p>
<h3 id="heading-aggregates-group-by-count-sum-and-so-on">Aggregates (GROUP BY, COUNT, SUM, and so on)</h3>
<p>Starting in PostgreSQL 10, aggregates can be pushed to the remote server when possible. The release notes state explicitly: “push aggregate functions to the remote server,” and explain that this <strong>reduces the amount of data that must be transferred from the remote server and offloads aggregate computation</strong> <a target="_blank" href="https://www.postgresql.org/docs/release/10.0/#:~:text=,Jeevan%20Chalke%2C%20Ashutosh%20Bapat">[7]</a>.</p>
<p>To qualify, both the grouping expressions and the aggregate functions themselves must be shippable. If the FDW cannot push an aggregate, it will fetch the raw rows and perform the aggregation locally.</p>
<p><strong>Plan smell:</strong> Look for a <code>GroupAggregate</code> node above a Foreign Scan that returns many rows. When the aggregate is pushed down, there will be no local aggregate node. Instead, the Remote SQL will include a <code>GROUP BY</code> clause.</p>
<h3 id="heading-order-by-and-limit">ORDER BY and LIMIT</h3>
<p>Prior to PostgreSQL 12, sorting and limiting were rarely pushed down. In version 12, Etsuro Fujita’s patch allows ORDER BY sorts and LIMIT clauses to be pushed to <code>postgres_fdw</code> foreign servers <strong>in more cases</strong> <a target="_blank" href="https://www.postgresql.org/docs/release/12.0/#:~:text=,Etsuro%20Fujita%29%20%C2%A7%20%C2%A7">[8]</a>. For the sort or limit to be pushed, the underlying scan must be pushable, and the ordering expression must be shippable. Partitioned queries or complicated join trees may still cause the sort or limit to be applied locally.</p>
<p><strong>Plan smell:</strong> A local Sort or Limit node above a Foreign Scan indicates the operation was not pushed down. Conversely, a Remote SQL statement containing ORDER BY and LIMIT indicates that pushdown succeeded.</p>
<h3 id="heading-distinct">DISTINCT</h3>
<p>Distinct operations can be pushed down when the distinct expression list is shippable. But if the distinct is combined with unshippable expressions, or if the distinct is applied after a join that cannot be pushed down, the FDW will retrieve all rows and perform the distinct locally.</p>
<h3 id="heading-window-functions">Window functions</h3>
<p>In practice, window functions are rarely pushed down through <code>postgres_fdw</code>. They often require ordering or partitioning semantics that are difficult to represent portably. If you see a <code>WindowAgg</code> node in your plan, it’s almost always local. That doesn’t mean you can't use window functions with foreign tables, but you should expect them to incur network and CPU costs.</p>
<h3 id="heading-version-differences">Version differences</h3>
<p>Postgres developers continue to improve the FDW layer. Here are some notable changes by version:</p>
<ol>
<li><p><strong>PostgreSQL 9.6</strong> introduced remote join pushdown and allowed UPDATE/DELETE pushdown. Before 9.6, all joins were local.</p>
</li>
<li><p><strong>PostgreSQL 10</strong> introduced aggregate pushdown, enabling remote GROUP BY and aggregate functions <a target="_blank" href="https://www.postgresql.org/docs/release/10.0/#:~:text=,Jeevan%20Chalke%2C%20Ashutosh%20Bapat">[7]</a>.</p>
</li>
<li><p><strong>PostgreSQL 12</strong> expanded ORDER BY and LIMIT pushdown <a target="_blank" href="https://www.postgresql.org/docs/release/12.0/#:~:text=,Etsuro%20Fujita%29%20%C2%A7%20%C2%A7">[8]</a>.</p>
</li>
<li><p><strong>PostgreSQL 15</strong> added pushdown for certain CASE expressions and other improvements.</p>
</li>
</ol>
<p>If you learned FDW behavior on an older version, revisit your assumptions.</p>
<h2 id="heading-pushdown-blockers-and-why-they-exist">Pushdown Blockers and Why They Exist</h2>
<p>When pushdown fails, it’s not due to bad luck. There’s always a reason grounded in safety or correctness. Here are the most common blockers and how to diagnose them.</p>
<h3 id="heading-nonimmutable-functions">Non‑immutable functions</h3>
<p>Functions marked <code>VOLATILE</code> or <code>STABLE</code> cannot be pushed down because their results may differ between the local and remote server. Examples include <code>now()</code>, <code>random()</code>, <code>current_user</code>, and user‑defined functions that look at session variables or query the database. Even functions you might think are harmless, like <code>age()</code> or <code>clock_timestamp()</code>, can cause pushdown to fail.</p>
<p><strong>Fix:</strong> Compute volatile values in your application or in a CTE before referencing the foreign table. For example, compute timestamp <code>'now' - interval '30 days'</code> as a constant and compare your <code>created_at</code> column against that constant. Alternatively, move the logic into a stored generated column on the remote table.</p>
<h3 id="heading-type-and-collation-mismatches">Type and collation mismatches</h3>
<p>The documentation warns that when types or collations don’t match between the local and remote tables, the remote server may interpret conditions differently <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=It%20is%20generally%20recommended%20that,differently%20from%20the%20local%20server">[1]</a>. This is particularly insidious when text comparisons, case‑insensitive collations, or non‑default locale settings are used. If Postgres can't guarantee the same semantics, it will pull rows locally and evaluate the expression.</p>
<p><strong>Fix:</strong> Make sure that your foreign table definition uses the same data types and collations as the remote table. When in doubt, explicitly cast values to a common type.</p>
<h3 id="heading-crossserver-joins">Cross‑server joins</h3>
<p>Joins across different foreign servers cannot be pushed down. The FDW can only ship a join when both tables reside on the same remote server and use the same user mapping <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=When%20,clauses">[6]</a>. Otherwise, it will perform two separate scans and join the results locally.</p>
<p><strong>Fix:</strong> If you frequently join tables across servers, consider consolidating the tables on a single server, materializing a view on one side, or pulling the smaller table into a temporary local table before joining.</p>
<h3 id="heading-mixed-local-and-foreign-joins">Mixed local and foreign joins</h3>
<p>A join between a local table and a foreign table will not be pushed down. Even though the foreign side might be pushdown‑eligible, the FDW cannot join it with local data on the remote server. A nested loop with a parameterized foreign scan is the typical pattern here, resulting in many remote calls.</p>
<p><strong>Fix:</strong> Filter or aggregate as much as possible on the foreign side first (via a CTE or by materializing a subset) before joining to local tables.</p>
<h3 id="heading-remote-session-settings-and-search-paths">Remote session settings and search paths</h3>
<p>Because <code>postgres_fdw</code> sets a restricted <code>search_path</code>, <code>TimeZone</code>, <code>DateStyle</code>, and <code>IntervalStyle</code> in remote sessions <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=In%20the%20remote%20sessions%20opened,their%20expected%20search%20path%20environment">[4]</a>, any functions you call must be schema‑qualified or otherwise compatible. If a function relies on the current search path or session settings, it may break or produce different results on the remote side.</p>
<p><strong>Fix:</strong> Schema‑qualify remote functions and ensure that any environment‑dependent logic is safe to execute under the default FDW session settings. If necessary, attach <code>SET search_path</code> or other settings to your remote functions.</p>
<h3 id="heading-troubleshooting-matrix">Troubleshooting matrix</h3>
<p>The table below maps symptoms in your <code>EXPLAIN</code> plan to likely causes and fixes. Use it as a quick diagnostic tool when something looks off.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td><strong>Symptom in plan</strong></td><td><strong>Likely cause</strong></td><td><strong>Suggested fix</strong></td></tr>
</thead>
<tbody>
<tr>
<td>Foreign Scan has loops much greater than 1</td><td>Parameterized remote lookup caused by nested loop, join conditions not shippable</td><td>Rewrite join so the FDW can ship a single joined query, or batch remote requests via an <code>IN</code> list or temporary table</td></tr>
<tr>
<td>Broad Remote SQL that lacks scope predicates</td><td><code>WHERE</code> clause contains non‑immutable functions or unsupported operators</td><td>Replace volatile functions with constants or allow‑list extension functions, ensure types and collations match</td></tr>
<tr>
<td>Local Hash Join or Merge Join between two foreign tables</td><td>Join could not be pushed down (different servers, user mappings, or unshippable join expression)</td><td>Consolidate tables on one server, align user mappings, or rewrite the join condition</td></tr>
<tr>
<td>Local Sort, Limit, or Unique on top of a Foreign Scan</td><td><code>ORDER BY</code>, <code>LIMIT</code>, or <code>DISTINCT</code> could not be pushed down</td><td>Simplify sort expressions, push filters deeper, check PG version for improvements</td></tr>
<tr>
<td>Plan runs but gives wrong results when pushdown is enabled</td><td>Semantic mismatch due to type/collation differences or remote session settings <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=It%20is%20generally%20recommended%20that,differently%20from%20the%20local%20server">[1]</a> <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=In%20the%20remote%20sessions%20opened,their%20expected%20search%20path%20environment">[4]</a></td><td>Align types/collations, schema‑qualify functions, use stable session settings</td></tr>
</tbody>
</table>
</div><h2 id="heading-reading-explain-like-a-pro">Reading EXPLAIN Like a Pro</h2>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771117830315/62ca8fde-2638-4ae1-b968-1100ac5251bb.png" alt="SQL execution plan analysis table with columns: exclusive, inclusive, rows x, rows, loops, and node details. Rows display Nested Loop Join, Hash Join, and Seq Scan operations with costs, times, and buffers. Highlighted cells indicate notable metrics." class="image--center mx-auto" width="1579" height="823" loading="lazy"></p>
<p>Many developers skim <code>EXPLAIN</code> plans for local queries, looking at the top nodes and overall cost. For FDW queries, you must invert that habit: read the foreign parts first. The Remote SQL string tells you what the remote server is being asked to do, and the loops field tells you how many times that remote call is executed.</p>
<h3 id="heading-inspect-the-foreign-scan-nodes">Inspect the Foreign Scan nodes</h3>
<p>Start by finding the Foreign Scan node(s). In <code>EXPLAIN (VERBOSE)</code>, each foreign scan includes a line like:</p>
<pre><code class="lang-pgsql">Remote <span class="hljs-keyword">SQL</span>: <span class="hljs-keyword">SELECT</span> ...
</code></pre>
<p>This line is not a trivial – it’s the actual SQL that will run on the remote server. Read it carefully. Does it include your <code>WHERE</code> predicates? Does it include your join conditions? If not, you know the local server will pick up the slack.</p>
<p>Look at the loops column. If the loops exceed 1, the same remote query is executed multiple times. For example:</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">Foreign</span> Scan <span class="hljs-keyword">on</span> <span class="hljs-built_in">public</span>.user_entity  (<span class="hljs-keyword">rows</span>=<span class="hljs-number">1</span> loops=<span class="hljs-number">416</span>)
  Remote <span class="hljs-keyword">SQL</span>: <span class="hljs-keyword">SELECT</span> id, tenant_id <span class="hljs-keyword">FROM</span> <span class="hljs-built_in">public</span>.user_entity <span class="hljs-keyword">WHERE</span> enabled <span class="hljs-keyword">AND</span> service_account_client_link <span class="hljs-keyword">IS</span> <span class="hljs-keyword">NULL</span> <span class="hljs-keyword">AND</span> id = <span class="hljs-meta">$1</span>
</code></pre>
<p>This is the “N+1” problem in disguise. The plan executes the foreign scan once per outer row. Multiply the per‑loop cost by the number of loops to understand why the query is slow. The fix is to rewrite the query so that the join and filters are applied in a single remote call.</p>
<h3 id="heading-recognize-initplan-vs-subplan">Recognize InitPlan vs SubPlan</h3>
<p>An InitPlan runs once and caches its result. A SubPlan can run per outer row. In FDW queries, subplans often drive parameterized remote scans. If you see a SubPlan attached to a nested loop that feeds a foreign scan, suspect a parameterized remote lookup and look for ways to turn it into an InitPlan or merge it into a single remote query.</p>
<h3 id="heading-understand-cte-materialization">Understand CTE materialization</h3>
<p>Common table expressions (CTEs) behave differently depending on whether they are marked <code>MATERIALIZED</code> or <code>NOT MATERIALIZED</code>. A materialized CTE is computed once and stored in a temporary structure, then read by the rest of the query. A non‑materialized CTE is inlined into the parent query, allowing optimizations to span across the boundary.</p>
<p>In PostgreSQL 12 and later, CTEs are inlined by default unless they’re referenced multiple times or explicitly marked <code>MATERIALIZED</code>. Materializing a CTE that contains a foreign scan can freeze a broad remote fetch and prevent later clauses from being pushed down. On the other hand, materialization can prevent repeated remote scans if the CTE is referenced multiple times. Use this lever deliberately to control where remote work happens.</p>
<h3 id="heading-annotated-example">Annotated example</h3>
<p>Let's annotate a simplified excerpt from a real plan. The goal is to show how to quickly read the relevant parts.</p>
<pre><code class="lang-pgsql">Nested <span class="hljs-keyword">Loop</span>  (<span class="hljs-keyword">rows</span>=<span class="hljs-number">414</span> loops=<span class="hljs-number">1</span>)
  -&gt; Hash <span class="hljs-keyword">Join</span>  (<span class="hljs-keyword">rows</span>=<span class="hljs-number">416</span> loops=<span class="hljs-number">1</span>)
       -&gt; <span class="hljs-keyword">Foreign</span> Scan <span class="hljs-keyword">on</span> <span class="hljs-built_in">public</span>.user_entity (<span class="hljs-keyword">rows</span>=<span class="hljs-number">1</span> loops=<span class="hljs-number">416</span>)
            Remote <span class="hljs-keyword">SQL</span>: <span class="hljs-keyword">SELECT</span> id, tenant_id <span class="hljs-keyword">FROM</span> <span class="hljs-built_in">public</span>.user_entity <span class="hljs-keyword">WHERE</span> enabled <span class="hljs-keyword">AND</span> service_account_client_link <span class="hljs-keyword">IS</span> <span class="hljs-keyword">NULL</span> <span class="hljs-keyword">AND</span> id = <span class="hljs-meta">$1</span>
  -&gt; <span class="hljs-keyword">Foreign</span> Scan <span class="hljs-keyword">on</span> <span class="hljs-built_in">public</span>.user_attribute (<span class="hljs-keyword">rows</span>=<span class="hljs-number">671</span> loops=<span class="hljs-number">1</span>)
       Remote <span class="hljs-keyword">SQL</span>: <span class="hljs-keyword">SELECT</span> ua.user_id, ua.<span class="hljs-keyword">value</span> <span class="hljs-keyword">FROM</span> user_attribute ua <span class="hljs-keyword">JOIN</span> user_entity u <span class="hljs-keyword">ON</span> ua.user_id = u.id <span class="hljs-keyword">JOIN</span> tenant r <span class="hljs-keyword">ON</span> u.tenant_id = r.id <span class="hljs-keyword">WHERE</span> ua.name = <span class="hljs-string">'attribute A'</span> <span class="hljs-keyword">AND</span> r.name = <span class="hljs-string">'demo'</span> <span class="hljs-keyword">AND</span> u.enabled <span class="hljs-keyword">AND</span> u.service_account_client_link <span class="hljs-keyword">IS</span> <span class="hljs-keyword">NULL</span> <span class="hljs-keyword">AND</span> (g.name = <span class="hljs-string">'keycloak-group-a'</span> <span class="hljs-keyword">OR</span> g.parent_group = <span class="hljs-meta">$1</span>)
</code></pre>
<p>In the old plan, the first Foreign Scan executed 416 times, each time retrieving a single row. The Remote SQL only applies the filter on enabled and service_account_client_link – it doesn’t include the tenant or group scoping. That scoping is applied by the nested loop outside the foreign scan.</p>
<p>In the refactored plan, the second Foreign Scan results from combining user_attribute, user_entity, user_group_membership, keycloak_group, and tenant into a single remote query. It retrieves 671 rows in a single query and includes all relevant filters. There is no repeated remote call. The timing difference is driven by the different loop values and the selectivity of the Remote SQL.</p>
<h2 id="heading-how-to-tune-postgresfdw">How to Tune postgres_fdw</h2>
<p>Once you've structured your query for maximum pushdown, tuning knobs let you squeeze out further performance improvements and adjust planner decisions.</p>
<h3 id="heading-fetchsize">fetch_size</h3>
<p><code>fetch_size</code> controls how many rows <code>postgres_fdw</code> retrieves per network fetch. The default is <code>100</code> rows <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=">[9]</a>. A small fetch size means more round-trips and lower memory usage. A larger fetch size reduces network overhead at the cost of buffering more rows in memory.</p>
<p>In practice, increasing <code>fetch_size</code> to a few thousand can reduce latency for large result sets. It’s specified either at the foreign server or foreign table level:</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">ALTER</span> <span class="hljs-keyword">SERVER</span> foreign_server <span class="hljs-keyword">OPTIONS</span> (<span class="hljs-keyword">ADD</span> fetch_size <span class="hljs-string">'1000'</span>);
<span class="hljs-keyword">ALTER</span> <span class="hljs-keyword">FOREIGN</span> <span class="hljs-keyword">TABLE</span> remote_table <span class="hljs-keyword">OPTIONS</span> (<span class="hljs-keyword">ADD</span> fetch_size <span class="hljs-string">'1000'</span>);
</code></pre>
<h3 id="heading-useremoteestimate">use_remote_estimate</h3>
<p>By default, the planner estimates the cost of foreign scans using local statistics. This can be wildly inaccurate if the foreign table has a different data distribution. Setting <code>use_remote_estimate</code> to true tells <code>postgres_fdw</code> to run <code>EXPLAIN</code> on the remote server to get row count and cost estimates. This can dramatically improve join order selection at the cost of an additional remote query during planning <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=This%20option%2C%20which%20can%20be,false">[3]</a>. You can set this per table or per server:</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">ALTER</span> <span class="hljs-keyword">SERVER</span> foreign_server <span class="hljs-keyword">OPTIONS</span> (<span class="hljs-keyword">SET</span> use_remote_estimate <span class="hljs-string">'true'</span>);
</code></pre>
<h3 id="heading-fdwstartupcost-and-fdwtuplecost">fdw_startup_cost and fdw_tuple_cost</h3>
<p>These cost parameters model the overhead of starting a foreign scan and the cost per row fetched. Adjusting them can influence the planner’s choice of join strategy. A higher <code>fdw_startup_cost</code> discourages the planner from choosing plans with many small foreign scans (which might generate many remote calls). A higher <code>fdw_tuple_cost</code> discourages plans that fetch large numbers of rows <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=This%20option%2C%20which%20can%20be,false">[3]</a>. Use these only after you have solid evidence from <code>EXPLAIN</code> and experiments.</p>
<h3 id="heading-analyze-and-analyzesampling">ANALYZE and analyze_sampling</h3>
<p>Running <code>ANALYZE</code> on a foreign table collects local statistics by sampling the remote table <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=This%20option%2C%20which%20can%20be,false">[3]</a>. Accurate stats are essential for good estimates when <code>use_remote_estimate</code> is false.</p>
<p>But if the remote table changes frequently, these stats become stale quickly. The <code>analyze_sampling</code> option controls whether sampling happens on the remote side or locally. When <code>analyze_sampling</code> is set to <code>random</code>, <code>system</code>, <code>bernoulli</code>, or <code>auto</code>, <code>ANALYZE</code> will sample rows remotely instead of pulling all rows into the local server<a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=This%20option%2C%20which%20can%20be,false">[3]</a>.</p>
<h3 id="heading-extensions">extensions</h3>
<p>The extensions option lists extensions whose functions and operators can be shipped to the remote server <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=">[2]</a>. If you rely on functions from citext, <code>pg_trgm</code>, or other extensions, add them to the server definition:</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">ALTER</span> <span class="hljs-keyword">SERVER</span> foreign_server <span class="hljs-keyword">OPTIONS</span> (<span class="hljs-keyword">SET</span> extensions <span class="hljs-string">'citext,pg_trgm'</span>);
</code></pre>
<h3 id="heading-a-quick-knob-impact-table">A quick knob impact table</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td><strong>Knob</strong></td><td><strong>Primary effect</strong></td><td><strong>When to change it</strong></td><td><strong>Possible downside</strong></td></tr>
</thead>
<tbody>
<tr>
<td>fetch_size</td><td>Number of rows per fetch</td><td>Result sets are large and latency dominates</td><td>Too large consumes memory</td></tr>
<tr>
<td>use_remote_estimate</td><td>Better row count/cost estimates</td><td>Planner misestimates foreign scans</td><td>Extra remote queries during planning</td></tr>
<tr>
<td>fdw_startup_cost</td><td>Penalty per foreign scan</td><td>Planner chooses many small foreign scans</td><td>Wrong values bias the planner</td></tr>
<tr>
<td>fdw_tuple_cost</td><td>Cost per row fetched</td><td>Planner pulls too many rows</td><td>Mis‑tuned values mislead planner</td></tr>
<tr>
<td>extensions</td><td>Which extension functions are shippable</td><td>Using extension functions in predicates</td><td>Extensions must exist and match on both servers</td></tr>
</tbody>
</table>
</div><h2 id="heading-schema-and-index-recommendations">Schema and Index Recommendations</h2>
<p>Pushdown doesn’t eliminate the need for good indexes. In fact, effective pushdown depends on the remote server having indexes that support the filter and join predicates you’re shipping.</p>
<p>Below are some patterns to watch for in FDW queries and the indexes that support them. You can adapt these to your own schema.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td><strong>Table</strong></td><td><strong>Access pattern</strong></td><td><strong>Recommended index</strong></td><td><strong>Why</strong></td></tr>
</thead>
<tbody>
<tr>
<td>tenant (remote)</td><td>Filter by tenant.name</td><td>UNIQUE (name) or BTREE (name)</td><td>Resolves tenant ID quickly</td></tr>
<tr>
<td>keycloak_group (remote)</td><td>Filter by name, join by tenant_id, filter on parent_group</td><td>Composite (tenant_id, name) and (parent_group)</td><td>Supports resolving root group and walking one‑level hierarchy</td></tr>
<tr>
<td>user_group_membership (remote)</td><td>Join by user_id, filter by group_id</td><td>BTREE (group_id, user_id)</td><td>Efficiently finds users in a set of groups</td></tr>
<tr>
<td>user_attribute (remote)</td><td>Filter by name, join by user_id</td><td>Composite (name, user_id) (optionally include value)</td><td>Matches “attribute name → users → values” flow</td></tr>
<tr>
<td>user_entity (remote)</td><td>Filter by tenant_id, enabled, service_account_client_link IS NULL, join by id</td><td>Partial index on (tenant_id, id) with predicate on enabled and service_account_client_link IS NULL</td><td>Helps remote planner start from user table when tenant and user filters are applied</td></tr>
<tr>
<td>filtercategory (local)</td><td>Filter by category &amp;&amp; uuid[], join on (entitytype, entityid)</td><td>GIN index on category, BTREE (entitytype, entityid)</td><td>Speeds array overlap checks and join predicate</td></tr>
</tbody>
</table>
</div><p>In general, indexes should reflect the join order you expect the remote planner to use. If your Remote SQL starts with:</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">FROM</span> user_attribute ua <span class="hljs-keyword">JOIN</span> user_entity u <span class="hljs-keyword">ON</span> ua.user_id = u.id <span class="hljs-keyword">JOIN</span> user_group_membership ugm <span class="hljs-keyword">ON</span> ...
</code></pre>
<p>ensure that indexes exist on <code>user_attribute(user_id</code>) and <code>user_group_membership(user_id)</code>.</p>
<h2 id="heading-benchmarking-methodology">Benchmarking Methodology</h2>
<p>It’s easy to claim a performance improvement without proper measurement. Here's a repeatable method you can use to benchmark FDW query changes.</p>
<ol>
<li><p><strong>Warm the caches.</strong> Run each query once to load data into the remote buffer cache and the local FDW connection. Discard the timings.</p>
</li>
<li><p><strong>Measure latencies.</strong> Use EXPLAIN (ANALYZE, BUFFERS, VERBOSE) to capture execution times, buffer usage, and remote row counts. Be aware that EXPLAIN ANALYZE adds overhead, so record the raw execution time if possible by running the query directly.</p>
</li>
<li><p><strong>Record remote metrics.</strong> On the remote server, enable pg_stat_statements and track the calls, total_time, and rows for each remote query. This gives you a per‑query breakdown and confirms what Remote SQL is executed.</p>
</li>
<li><p><strong>Control for concurrency and network latency.</strong> Run benchmarks during a quiet period or isolate the test cluster. If your environment has high network latency, record the round‑trip time separately to attribute delays.</p>
</li>
<li><p><strong>Compare apples to apples.</strong> Benchmark the old and new queries under identical conditions. Use the same sample data, same remote server, and same connection settings.</p>
</li>
<li><p><strong>Look at row counts.</strong> The primary goal of pushdown is to reduce the number of rows shipped. Compare the rows column of each Foreign Scan node.</p>
</li>
</ol>
<p>Here's a simple matrix you can use to record your experiments:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td><strong>Scenario</strong></td><td><strong>What you're testing</strong></td><td><strong>Expected change in Remote SQL</strong></td><td><strong>Metrics to record</strong></td></tr>
</thead>
<tbody>
<tr>
<td>Baseline (old query)</td><td>Starting point: broad remote scans + local joins</td><td>Remote SQL lacks scoping predicates</td><td>p50/p95 latency, remote row count, local sort/hash time</td></tr>
<tr>
<td>Refactor (new query)</td><td>Join + filter pushdown</td><td>Remote SQL includes joins and filters</td><td>Same metrics, plus remote row count</td></tr>
<tr>
<td>Introduce a volatile function</td><td>Pushdown blocker test</td><td>Clause removed from Remote SQL</td><td>Remote row count increases, local filter cost increases</td></tr>
<tr>
<td>Type or collation mismatch</td><td>Semantic risk test</td><td>Remote SQL might change behavior or lose pushdown</td><td>Compare correctness and row counts</td></tr>
<tr>
<td>ORDER/LIMIT pushdown</td><td>Version‑dependent test</td><td>Remote SQL includes ORDER BY, LIMIT</td><td>Sort time shifts to remote. Row count should remain</td></tr>
<tr>
<td>use_remote_estimate on/off</td><td>Planning accuracy test</td><td>Planner uses remote estimates</td><td>Planning time, join order, and runtime difference</td></tr>
</tbody>
</table>
</div><h2 id="heading-monitoring-and-logging">Monitoring and Logging</h2>
<p>In production, you need to know when a query starts misbehaving. There are two places to look: the local server and the remote server.</p>
<h3 id="heading-local-metrics">Local metrics</h3>
<ol>
<li><p><strong>pg_stat_statements.</strong> This extension tracks planning and execution times, row counts, and buffer hits for each query. Look for high total times relative to rows or calls.</p>
</li>
<li><p><strong>Auto Explain or auto_explain.</strong> Turn on <code>auto_explain.log_min_duration_statement</code> to capture slow queries with plans. This will show you the Remote SQL executed and whether the plan changed.</p>
</li>
<li><p><strong>Connection pool metrics.</strong> Monitor connection counts and wait events related to FDW operations (for example, PostgresFdwConnect, PostgresFdwGetResult) as described in the documentation <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=,Extension">[10]</a>.</p>
</li>
</ol>
<h3 id="heading-remote-metrics">Remote metrics</h3>
<ol>
<li><p><strong>pg_stat_statements on the remote server.</strong> This lets you see which Remote SQL queries are being executed, how often, and how long they take. Compare these with the Remote SQL strings in your local EXPLAIN plans.</p>
</li>
<li><p><strong>Server logs.</strong> Increase <code>log_statement</code> or <code>log_min_duration_statement</code> on the remote server to capture long-running remote queries.</p>
</li>
</ol>
<p>Correlating local and remote metrics can reveal patterns such as a new code path causing a surge in remote queries or pushdown failures, leading to heavy remote scans.</p>
<h2 id="heading-case-study-refactoring-a-keycloak-coverage-query">Case Study: Refactoring a Keycloak Coverage Query</h2>
<p>The theory above may seem abstract until you see it play out in practice. Let's walk through a real example inspired by a Keycloak integration.</p>
<p>The original query calculated coverage: given a list of category IDs, it returned the percentage of users who had attributes mapped to those categories and a JSON array of entity counts. The query used a CTE to build a list of scoped users, then joined it with user attributes, category mappings, and a few other tables.</p>
<h3 id="heading-symptom">Symptom</h3>
<p>In a test environment with 100K user records, the query averaged 166 ms. This was slower than expected. Running <code>EXPLAIN (ANALYZE, BUFFERS, VERBOSE)</code> showed two foreign scans on the Keycloak database. The first scanned <code>user_entity</code> 416 times (loops = 416). The second pulled all rows from <code>user_attribute</code> where <code>name = 'attributeA'</code> before filtering by tenant and group locally.</p>
<p>Here's a simplified excerpt (numbers are approximate):</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">Foreign</span> Scan <span class="hljs-keyword">on</span> <span class="hljs-built_in">public</span>.user_entity  (actual <span class="hljs-type">time</span>=<span class="hljs-number">0.117</span>.<span class="hljs-number">.0</span><span class="hljs-number">.117</span> <span class="hljs-keyword">rows</span>=<span class="hljs-number">1</span> loops=<span class="hljs-number">416</span>)
  Remote <span class="hljs-keyword">SQL</span>: <span class="hljs-keyword">SELECT</span> id, tenant_id <span class="hljs-keyword">FROM</span> <span class="hljs-built_in">public</span>.user_entity <span class="hljs-keyword">WHERE</span> (enabled <span class="hljs-keyword">AND</span> service_account_client_link <span class="hljs-keyword">IS</span> <span class="hljs-keyword">NULL</span> <span class="hljs-keyword">AND</span> id = <span class="hljs-meta">$1</span>)
<span class="hljs-keyword">Foreign</span> Scan <span class="hljs-keyword">on</span> <span class="hljs-built_in">public</span>.user_attribute  (actual <span class="hljs-type">time</span>=<span class="hljs-number">41.267</span>.<span class="hljs-number">.80</span><span class="hljs-number">.352</span> <span class="hljs-keyword">rows</span>=<span class="hljs-number">80739</span> loops=<span class="hljs-number">1</span>)
  Remote <span class="hljs-keyword">SQL</span>: <span class="hljs-keyword">SELECT</span> <span class="hljs-keyword">value</span>, user_id <span class="hljs-keyword">FROM</span> <span class="hljs-built_in">public</span>.user_attribute <span class="hljs-keyword">WHERE</span> ((<span class="hljs-string">'attributeA'</span> = <span class="hljs-type">name</span>))
</code></pre>
<p>The first scan performed a single-row lookup 416 times. The second scan retrieved 80,739 rows because the only condition pushed down was <code>name = 'attributeA'</code>. Tenant and group scoping occurred locally. That meant 80k rows were transferred over the network and then filtered down to about 671 on the local side.</p>
<h3 id="heading-diagnosis">Diagnosis</h3>
<p>There were two main issues.</p>
<p>First was the N+1 remote calls on user_entity. The join to <code>user_entity</code> was not pushed down, so the plan executed a remote lookup for each row from <code>user_group_membership</code>. This created 416 remote queries.</p>
<p>Second was the unscoped attribute fetch. Because the <code>WHERE</code> clause included <code>user_entity.tenant_id = tenant.id</code> and <code>keycloak_group.name = 'groupA'</code> in a higher CTE, the FDW could not see those predicates when scanning <code>user_attribute</code>. It therefore fetched all rows with <code>name = 'attributeA'</code> and left the tenant and group filters to the local side.</p>
<h3 id="heading-refactor">Refactor</h3>
<p>The fix was to inline the tenant and group joins into the user_attribute scan to avoid the nested-loop pattern. The refactored <code>selected_user_attributes</code> CTE looked like this (simplified for readability):</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">WITH</span> selected_user_attributes <span class="hljs-keyword">AS</span> (
  <span class="hljs-keyword">SELECT</span> <span class="hljs-keyword">DISTINCT</span> ua.user_id, ua.<span class="hljs-keyword">value</span>
  <span class="hljs-keyword">FROM</span> <span class="hljs-built_in">public</span>.user_attribute ua
  <span class="hljs-keyword">JOIN</span> <span class="hljs-built_in">public</span>.user_entity u <span class="hljs-keyword">ON</span> u.id = ua.user_id
  <span class="hljs-keyword">JOIN</span> <span class="hljs-built_in">public</span>.user_group_membership ugm <span class="hljs-keyword">ON</span> ugm.user_id = u.id
  <span class="hljs-keyword">JOIN</span> <span class="hljs-built_in">public</span>.keycloak_group g <span class="hljs-keyword">ON</span> g.id = ugm.group_id
  <span class="hljs-keyword">JOIN</span> <span class="hljs-built_in">public</span>.tenant r <span class="hljs-keyword">ON</span> r.id = u.tenant_id
  <span class="hljs-keyword">WHERE</span> ua.name = <span class="hljs-string">'attributeA'</span>
    <span class="hljs-keyword">AND</span> u.enabled
    <span class="hljs-keyword">AND</span> u.service_account_client_link <span class="hljs-keyword">IS</span> <span class="hljs-keyword">NULL</span>
    <span class="hljs-keyword">AND</span> r.name = <span class="hljs-string">'tenantA'</span>
    <span class="hljs-keyword">AND</span> (g.name = <span class="hljs-string">'groupA'</span> <span class="hljs-keyword">OR</span> g.parent_group = (
         <span class="hljs-keyword">SELECT</span> id <span class="hljs-keyword">FROM</span> <span class="hljs-built_in">public</span>.keycloak_group <span class="hljs-keyword">WHERE</span> <span class="hljs-type">name</span> = <span class="hljs-string">'groupA'</span> <span class="hljs-keyword">AND</span> tenant_id= r.id
    ))
)
</code></pre>
<p>This single query expresses the same scoping logic that previously lived in separate CTEs. Because all the join conditions are on the same foreign server and use built‑in operators, the FDW can push down the entire join. The new plan looked like this:</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">Foreign</span> Scan  (actual <span class="hljs-type">time</span>=<span class="hljs-number">7.840</span>.<span class="hljs-number">.7</span><span class="hljs-number">.856</span> <span class="hljs-keyword">rows</span>=<span class="hljs-number">671</span> loops=<span class="hljs-number">1</span>)
  Remote <span class="hljs-keyword">SQL</span>: <span class="hljs-keyword">SELECT</span> ua.user_id, ua.<span class="hljs-keyword">value</span> <span class="hljs-keyword">FROM</span> user_attribute ua <span class="hljs-keyword">JOIN</span> user_entity u <span class="hljs-keyword">ON</span> ua.user_id = u.id <span class="hljs-keyword">JOIN</span> user_group_membership ugm <span class="hljs-keyword">ON</span> ugm.user_id = u.id <span class="hljs-keyword">JOIN</span> keycloak_group g <span class="hljs-keyword">ON</span> g.id = ugm.group_id <span class="hljs-keyword">JOIN</span> tenant r <span class="hljs-keyword">ON</span> u.tenant_id= r.id <span class="hljs-keyword">WHERE</span> ua.name = <span class="hljs-string">'attributeA'</span> <span class="hljs-keyword">AND</span> u.enabled <span class="hljs-keyword">AND</span> u.service_account_client_link <span class="hljs-keyword">IS</span> <span class="hljs-keyword">NULL</span> <span class="hljs-keyword">AND</span> r.name = <span class="hljs-string">'tenantA'</span> <span class="hljs-keyword">AND</span> (g.name = <span class="hljs-string">'groupA'</span> <span class="hljs-keyword">OR</span> g.parent_group = <span class="hljs-meta">$1</span>)
</code></pre>
<p>Only one remote query is executed, and it returns 671 rows. Tenant and group scoping occur on the remote server. There is no nested loop or repeated remote scan. The final runtime dropped to <strong>about 25 ms</strong>.</p>
<h3 id="heading-why-it-improved">Why it improved</h3>
<ol>
<li><p><strong>Fewer rows crossing the network.</strong> The old plan fetched 80k attribute rows and filtered them locally. The new plan fetched only the 671 scoped rows.</p>
</li>
<li><p><strong>No repeated remote calls.</strong> The old plan executed 416 remote scans of <code>user_entity</code>. The new plan performs one joined remote query.</p>
</li>
<li><p><strong>Less local work.</strong> Because the join and filtering happen remotely, the local side no longer hashes or filters large sets.</p>
</li>
</ol>
<h3 id="heading-key-takeaway">Key takeaway</h3>
<p>If you see a Foreign Scan with a high loops count or a Remote SQL that doesn’t contain your filters and joins, you’re leaving performance on the table. Merging filters and joins into a single remote query (subject to shippability rules) often yields orders-of-magnitude improvements.</p>
<h2 id="heading-checklist-and-troubleshooting-guide">Checklist and Troubleshooting Guide</h2>
<p>The following steps summarize how to approach FDW performance tuning:</p>
<ol>
<li><p><strong>Inspect the Remote SQL.</strong> Always run <code>EXPLAIN (VERBOSE)</code> and look at what is being sent to the remote. If your predicates are missing, the FDW isn't pushing them down.</p>
</li>
<li><p><strong>Check loops.</strong> If the loops are greater than 1 on a Foreign Scan, you are paying for repeated remote calls. Rewrite the query or reorder the joins to make the foreign scan run once.</p>
</li>
<li><p><strong>Make predicates shippable.</strong> Replace volatile functions with constants or parameters. Ensure operators and functions are built‑in or explicitly allow‑listed via the extensions option <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=">[2]</a>.</p>
</li>
<li><p><strong>Align types and collations.</strong> Use the same data types and collations on both sides to avoid semantic mismatches <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=It%20is%20generally%20recommended%20that,differently%20from%20the%20local%20server">[1]</a>.</p>
</li>
<li><p><strong>Push joins to the same server.</strong> Consolidate tables on one foreign server if possible. Joins across servers cannot be pushed down <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=When%20,clauses">[6]</a>.</p>
</li>
<li><p><strong>Use use_remote_estimate when planning seems off.</strong> Enabling remote estimates can improve join order selection <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=This%20option%2C%20which%20can%20be,false">[3]</a>.</p>
</li>
<li><p><strong>Tune fetch_size and costs</strong> if your queries transfer many rows. A bigger fetch_size reduces round-trip; adjusting <code>fdw_startup_cost</code> and <code>fdw_tuple_cost</code> influences the planner <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=This%20option%2C%20which%20can%20be,false">[3]</a>.</p>
</li>
<li><p><strong>Analyze foreign tables</strong> if you rely on local cost estimates. Keep in mind that stats can get stale quickly <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=This%20option%2C%20which%20can%20be,false">[3]</a>.</p>
</li>
<li><p><strong>Monitor both servers.</strong> Use <code>pg_stat_statements</code> on local and remote servers to see how often remote queries run and how long they take.</p>
</li>
<li><p><strong>Test version upgrades.</strong> Each major release improves FDW pushdown semantics (for example, aggregates in 10 <a target="_blank" href="https://www.postgresql.org/docs/release/10.0/#:~:text=,Jeevan%20Chalke%2C%20Ashutosh%20Bapat">[7]</a>, ORDER/LIMIT in 12 <a target="_blank" href="https://www.postgresql.org/docs/release/12.0/#:~:text=,Etsuro%20Fujita%29%20%C2%A7%20%C2%A7">[8]</a>). Retest after upgrading.</p>
</li>
</ol>
<h2 id="heading-case-study-takeaways">Case Study Takeaways</h2>
<p>Querying remote data with PostgreSQL’s <code>postgres_fdw</code> can be fast and convenient if you respect the underlying mechanics. Pushdown is the difference between streaming a trickle of relevant rows and hauling an ocean of data across the network. It isn't simply a matter of moving CPU cycles – it changes how much data moves, how many network round-trip occur, and how much your local server has to do.</p>
<p>The rules may seem restrictive – use only immutable functions, avoid cross‑server joins, align types and collations – but they exist to preserve correctness while enabling optimization.</p>
<p>By reading <code>EXPLAIN</code> from the bottom up, inspecting the Remote SQL, and understanding the shippability rules, you can spot slow patterns quickly. Armed with tuning knobs like <code>fetch_size</code> and <code>use_remote_estimate</code>, and a willingness to rewrite queries to make joins and filters pushable, you can often achieve dramatic performance gains without touching your hardware.</p>
<p>This case study shows that rewriting a query to enable a single-joined remote query reduced runtime from around <strong>166 ms to 25 ms</strong>. That sort of improvement is not rare. It’s what happens when you treat FDW queries as distributed queries rather than local queries in disguise.</p>
<p>The next time you debug a slow FDW query, remember this handbook. Check the Remote SQL. Count the loops. Ask yourself: “Am I doing the work close to the data, or am I bringing the data to the work?” Adjust accordingly, and you'll write queries that make the most of Postgres's federated capabilities while keeping your latency in check.</p>
<p>This section closes the case study loop and summarizes exactly what changed in the plan and why it produced a large end-to-end win. The following sections of the handbook turn that single win into a repeatable method: how Postgres determines what is shippable, how to quickly read FDW plans, which operations and versions matter, and how to debug common failure modes that prevent pushdown.</p>
<h2 id="heading-advanced-operations-a-deeper-dive-into-shippability">Advanced Operations: A Deeper Dive into Shippability</h2>
<p>The previous sections introduced the basic rules around what can be pushed to the remote and why. To really make sense of those rules, you need to see how they play out on the operations you use every day.</p>
<p>This section walks through filters, joins, aggregates, ordering, and limits, DISTINCT queries, and window functions in more detail. By the end, you should have a mental map of which operations to trust and which to double‑check when reading your plans.</p>
<h3 id="heading-filters-and-simple-predicates">Filters and simple predicates</h3>
<h4 id="heading-where-clauses-matter-more-than-you-think">WHERE clauses matter more than you think</h4>
<p>When you specify <code>WHERE attribute = 'value'</code> on a foreign table, the FDW will happily transmit that predicate to the remote server as long as the comparison uses built‑in types and immutable operators. For example:</p>
<ul>
<li><p><code>WHERE id = 42</code> is fine</p>
</li>
<li><p><code>WHERE lower(username) = 'hamdaan'</code> is fine if <code>lower()</code> is allow‑listed and immutable</p>
</li>
<li><p><code>WHERE created_at &gt;= now() - interval '7 days'</code> is not shippable because <code>now()</code> is volatile</p>
</li>
</ul>
<p>When such a predicate cannot be pushed, the FDW will fetch every row that matches all the shippable predicates and apply the rest locally. That means that a seemingly innocuous call to <code>now()</code> can blow up your network traffic.</p>
<p>The lesson is simple: compute volatile values up front (in your application or in a CTE) and reference them as constants in the query against the foreign table.</p>
<h4 id="heading-complex-expressions-are-not-automatically-unsafe">Complex expressions are not automatically unsafe</h4>
<p>Suppose you have <code>WHERE (status = 'active' AND (age BETWEEN 18 AND 29 OR age &gt; 65))</code>. This entire expression is shippable because it uses built‑in boolean logic, simple comparisons, and immutable operators. The FDW will deparse it into remote SQL and forward it. You only need to worry when one of the subexpressions introduces a function or operator that the FDW doesn’t recognize or cannot safely assume exists on the remote.</p>
<p>A good heuristic is: if you can express your filter using only simple comparisons, boolean logic, and built‑in functions, pushdown should work. When in doubt, check the Remote SQL.</p>
<h4 id="heading-array-and-json-operators">Array and JSON operators</h4>
<p>Modern Postgres makes heavy use of array and JSON functions. Many of these functions, like the array overlap operator <code>&amp;&amp;</code> used in the case study, are built‑in and can be shipped. But some JSON functions are provided by extensions (like <code>jsonb_path_query</code> or functions from the <code>pgjson</code> family).</p>
<p>If your filter uses one of these, ensure that the extension is available and allow‑listed on the foreign server. Otherwise, the FDW will fetch rows and perform the JSON logic locally. This is rarely what you want when dealing with large JSON columns.</p>
<h3 id="heading-joins-the-good-the-bad-and-the-ugly">Joins: the good, the bad, and the ugly</h3>
<h4 id="heading-sameserver-joins-are-your-friend">Same‑server joins are your friend</h4>
<p>If you join multiple foreign tables that are all defined on the same foreign server and user mapping, and if the join condition uses only shippable expressions, then the FDW can generate a single remote join. This is the ideal case.</p>
<p>For example, joining orders and customers on <code>orders.customer_id = customers.id</code> is pushable, as long as both tables reside on the same foreign server. The remote planner will use its own statistics and indexes to plan the join, and the local server will simply iterate through the result. Postgres 9.6 and later support this pattern <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=When%20,clauses">[6]</a>.</p>
<h4 id="heading-crossserver-joins-break-pushdown">Cross‑server joins break pushdown</h4>
<p>If you attempt to join two foreign tables that live on different servers (or even on the same remote server but with different user mappings), postgres_fdw will fetch the tables separately and join them locally. This is almost always slower than pushing the join down, because you end up transferring both tables in their entirety.</p>
<p>The FDW design team chose not to support cross‑server joins because there is no portable way to tell two remote servers to cooperate on a join. Your options are: replicate one table on the other server, materialize the smaller table locally before joining, or restructure the query to filter aggressively on each side before joining locally.</p>
<h4 id="heading-mixed-localforeign-joins-are-tricky">Mixed local/foreign joins are tricky</h4>
<p>Joining a local table to a foreign table cannot be pushed down, for straightforward reasons: the remote server has no access to your local data. A common pattern that triggers repeated remote calls looks like this:</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">SELECT</span> u.id, a.<span class="hljs-keyword">value</span>
<span class="hljs-keyword">FROM</span> users u
<span class="hljs-keyword">LEFT JOIN</span> user_attribute a
  <span class="hljs-keyword">ON</span> a.user_id = u.id <span class="hljs-keyword">AND</span> a.name = <span class="hljs-string">'favorite_color'</span>;
</code></pre>
<p>If <code>users</code> is a local table and <code>user_attribute</code> is foreign, the plan may use a nested loop: for each local u, it executes a remote lookup in user_attribute to retrieve attributes.</p>
<p>The fix is to flip the query: retrieve all relevant rows from <code>user_attribute</code> in one remote scan, then join them locally. Or, if possible, create a small temporary table on the remote side with your u.id values, perform the join entirely remotely, and then fetch the results.</p>
<h4 id="heading-join-conditions-matter">Join conditions matter</h4>
<p>Even when joining two foreign tables on the same server, an unshippable join condition will force the join to be local. For example, <code>JOIN ON textcol ILIKE '%foo%'</code> is not pushable because <code>ILIKE</code> might not exist or behave identically on the remote.</p>
<p>If you need case‑insensitive matching, consider lowercasing both sides: <code>LOWER(textcol) = 'foo'</code> (assuming the remote server has the <code>lower()</code> function available and allowed). Similarly, joining on a cast expression (for example, <code>JOIN ON CAST(a.id AS text) = b.text_id</code>) can block pushdown. Define your columns with matching types instead.</p>
<h3 id="heading-aggregates-and-grouping">Aggregates and grouping</h3>
<p>Aggregates are where the data movement story shines. When you can push down a <code>GROUP BY</code> and aggregate functions like <code>COUNT</code>, <code>SUM</code>, <code>AVG</code>, or <code>MAX</code>, you reduce the result set to just the aggregated rows. This can be a difference of several orders of magnitude.</p>
<p>Postgres 10 introduced aggregate pushdown <a target="_blank" href="https://www.postgresql.org/docs/release/10.0/#:~:text=,Jeevan%20Chalke%2C%20Ashutosh%20Bapat">[7]</a>. But not all aggregates are equal:</p>
<p><strong>Simple aggregates</strong> such as <code>COUNT(*)</code>, <code>SUM(col)</code>, <code>AVG(col)</code>, <code>MIN(col)</code>, and <code>MAX(col)</code> are shippable when applied to shippable expressions. Even <code>COUNT(DISTINCT col)</code> is often shippable, because the remote can deduplicate before counting. The FDW will wrap the aggregate in a remote query and return just the aggregated row.</p>
<p>If you see a GroupAggregate node on the local side, check whether all involved columns and functions are shippable. If they are, ensure that the join conditions above are also pushable.</p>
<p><strong>Filtered aggregates</strong> such as <code>COUNT(*) FILTER (WHERE x &gt; 5) or SUM(col) FILTER (WHERE status = 'active')</code> are often pushable, because they translate into <code>SUM(CASE WHEN condition THEN col ELSE 0 END) or COUNT(...)</code>. As long as the filter is shippable, the FDW will push it into the remote aggregate.</p>
<p><strong>User‑defined aggregates</strong> are rarely pushable. If you have a custom aggregate function, the FDW will not assume that it exists or behaves the same on the remote server. Even if you install the function on both servers, postgres_fdw won't push it unless the function is in an allow‑listed extension.</p>
<p><strong>Grouping sets and rollups</strong> are not currently pushable. When you write <code>GROUP BY GROUPING SETS (...) or ROLLUP(...)</code>, Postgres will compute the grouping locally even if the underlying scan is remote.</p>
<p>If you need complex rollups, consider performing them in two steps: push down the initial grouping to the remote server to reduce rows, then perform the rollup locally.</p>
<h3 id="heading-order-by-limit-and-distinct">ORDER BY, LIMIT, and DISTINCT</h3>
<p>Ordering and limiting rows may seem like purely cosmetic features, but they affect how much data is transferred. If the remote can sort and limit, the local server only receives the top N rows. If it cannot, the local server must sort everything.</p>
<p>Postgres 12 expanded the cases where <code>ORDER BY</code> and LIMIT are pushed down <a target="_blank" href="https://www.postgresql.org/docs/release/12.0/#:~:text=,Etsuro%20Fujita%29%20%C2%A7%20%C2%A7">[8]</a>. Here are guidelines:</p>
<ul>
<li><p><strong>Single foreign scan with simple sort:</strong> If your query selects from one foreign table and sorts by a shippable expression (for example, <code>ORDER BY created_at DESC</code>), the FDW will include <code>ORDER BY</code> in Remote SQL. It will also push down <code>LIMIT</code> and <code>OFFSET</code>. This is ideal because the remote server does the sort and sends only the top rows.</p>
</li>
<li><p><strong>Sort after join:</strong> If you sort after joining two foreign tables on the same server, and the join and sort expressions are shippable, the FDW may push both down. But if the sort requires columns from the local side or from a different remote server, the FDW cannot push it down.</p>
</li>
<li><p><strong>Sort after aggregation:</strong> Sorting aggregated results is often pushable as long as the aggregate itself is pushable. But when grouping occurs locally, the sort remains local.</p>
</li>
<li><p><strong>DISTINCT behaves like GROUP BY.</strong> If the distinct expression list is shippable, the FDW can push it down. If you write <code>SELECT DISTINCT ON (col1) col2, col3 FROM ...</code> and col3 is not part of the <code>DISTINCT</code> list, Postgres will treat this as <code>GROUP BY</code> and may push it. Be aware that <code>DISTINCT ON</code> semantics differ from plain <code>DISTINCT</code> and may not be pushable in older Postgres versions.</p>
</li>
</ul>
<h3 id="heading-window-functions-1">Window functions</h3>
<p>Window functions (for example, <code>ROW_NUMBER() OVER (PARTITION BY ...), RANK(), LAG(), LEAD()</code>) rely on ordering and partitioning across rows.</p>
<p>Postgres has not yet taught <code>postgres_fdw</code> how to push window functions. When you see a WindowAgg node in your plan, it’s almost always local. The FDW will fetch the rows, and the local server will sort, partition, and compute the window. If you need to run window functions on remote data, plan to transfer the data locally.</p>
<h3 id="heading-versionspecific-quirks">Version‑specific quirks</h3>
<p>The exact pushdown capabilities vary by release. When planning migrations or deciding whether to rely on a pushdown behavior, check the release notes:</p>
<ul>
<li><p><strong>9.6:</strong> first version to support pushdown of joins and sorts, and remote updates and deletes.</p>
</li>
<li><p><strong>10:</strong> introduced aggregate pushdown <a target="_blank" href="https://www.postgresql.org/docs/release/10.0/#:~:text=,Jeevan%20Chalke%2C%20Ashutosh%20Bapat">[7]</a>, significantly reducing network use for <code>GROUP BY</code> queries.</p>
</li>
<li><p><strong>11:</strong> improved partition pruning and join ordering for foreign tables.</p>
</li>
<li><p><strong>12:</strong> expanded <code>ORDER BY</code> and <code>LIMIT</code> pushdown <a target="_blank" href="https://www.postgresql.org/docs/release/12.0/#:~:text=,Etsuro%20Fujita%29%20%C2%A7%20%C2%A7">[8]</a>.</p>
</li>
<li><p><strong>15:</strong> added pushdown for simple <code>CASE</code> expressions and additional built‑in functions.</p>
</li>
<li><p><strong>17</strong> (development at the time of writing) continues to expand shippable constructs. Always test on your target version because subtle improvements can change what the FDW can ship.</p>
</li>
</ul>
<h2 id="heading-common-antipatterns-and-how-to-avoid-them">Common Anti‑Patterns and How to Avoid Them</h2>
<p>Everyone has run into FDW queries that seemed reasonable but turned out to be bottlenecks. Here are a few of the most common mistakes and how to correct them. These examples are deliberately simplified – so you can adapt them to your schema.</p>
<h3 id="heading-using-volatile-functions-in-predicates">Using volatile functions in predicates</h3>
<p><strong>Anti‑pattern:</strong></p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">SELECT</span> *
<span class="hljs-keyword">FROM</span> audit_logs
<span class="hljs-keyword">WHERE</span> event_ts &gt;= now() - <span class="hljs-type">interval</span> <span class="hljs-string">'1 day'</span>;
</code></pre>
<p><code>now()</code> is a volatile function, so the FDW refuses to push this predicate. It pulls all rows from audit_logs and filters them locally.</p>
<p><strong>Better:</strong></p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">SELECT</span> *
<span class="hljs-keyword">FROM</span> audit_logs
<span class="hljs-keyword">WHERE</span> event_ts &gt;= <span class="hljs-meta">$1</span>;
</code></pre>
<p>Compute <code>$1</code> (a timestamp) in your application or upstream query. Or compute it once in a CTE:</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">WITH</span> cutoff <span class="hljs-keyword">AS</span> (<span class="hljs-keyword">SELECT</span> now() - <span class="hljs-type">interval</span> <span class="hljs-string">'1 day'</span> <span class="hljs-keyword">AS</span> ts) <span class="hljs-keyword">SELECT</span> * <span class="hljs-keyword">FROM</span> audit_logs, cutoff <span class="hljs-keyword">WHERE</span> event_ts &gt;= cutoff.ts;
</code></pre>
<p>The FDW sees a constant and pushes the predicate.</p>
<h3 id="heading-joining-local-and-foreign-data-first">Joining local and foreign data first</h3>
<p><strong>Anti‑pattern:</strong></p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">SELECT</span> u.email, ua.<span class="hljs-keyword">value</span>
<span class="hljs-keyword">FROM</span> users u
<span class="hljs-keyword">LEFT JOIN</span> user_attribute ua <span class="hljs-keyword">ON</span> u.id = ua.user_id <span class="hljs-keyword">AND</span> ua.name = <span class="hljs-string">'favorite_movie'</span>;
</code></pre>
<p>This uses a local table (users) to drive a join to a foreign table (user_attribute). The FDW receives 10,000 individual remote queries if users have 10,000 rows. Each call fetches one or zero rows from user_attribute.</p>
<p><strong>Better:</strong></p>
<pre><code class="lang-pgsql"><span class="hljs-comment">-- Fetch all favorite movies remotely and join locally</span>
<span class="hljs-keyword">WITH</span> remote_movies <span class="hljs-keyword">AS</span> (
  <span class="hljs-keyword">SELECT</span> ua.user_id, ua.<span class="hljs-keyword">value</span>
  <span class="hljs-keyword">FROM</span> user_attribute ua
  <span class="hljs-keyword">WHERE</span> ua.name = <span class="hljs-string">'favorite_movie'</span>
)
<span class="hljs-keyword">SELECT</span> u.email, rm.<span class="hljs-keyword">value</span>
<span class="hljs-keyword">FROM</span> users u
<span class="hljs-keyword">LEFT JOIN</span> remote_movies rm <span class="hljs-keyword">ON</span> u.id = rm.user_id;
</code></pre>
<p>Now the FDW issues one query to fetch all relevant attributes, and the join is done locally in one pass.</p>
<h3 id="heading-crossserver-joins-without-materialization">Cross‑server joins without materialization</h3>
<p><strong>Anti‑pattern:</strong></p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">SELECT</span> *
<span class="hljs-keyword">FROM</span> remote_db1.orders o
<span class="hljs-keyword">JOIN</span> remote_db2.customers c <span class="hljs-keyword">ON</span> o.customer_id = c.id;
</code></pre>
<p>This is not pushable because the two tables are on different foreign servers. Postgres will fetch orders and customers separately and join them locally. If orders have 1 million rows and customers have 50,000 rows, you will transfer 1.05 million rows.</p>
<p><strong>Better:</strong> Replicate or materialize one side on the other server (or locally) before joining. For example, create a materialized view m_customers on remote_db1 containing just the id and name of the customers you need, then join orders and m_customers on the same server. Alternatively, copy customers into a temporary table on the local server and join there.</p>
<h3 id="heading-complex-expressions-on-join-keys">Complex expressions on join keys</h3>
<p><strong>Anti‑pattern:</strong></p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">SELECT</span> *
<span class="hljs-keyword">FROM</span> remote_table a
<span class="hljs-keyword">JOIN</span> remote_table b <span class="hljs-keyword">ON</span> CAST(a.key <span class="hljs-keyword">AS</span> <span class="hljs-type">text</span>) = b.key_text;
</code></pre>
<p>Casting a numeric key to text prevents pushdown. The remote server cannot use indexes and must return both tables. The local server performs the join and cast.</p>
<p><strong>Better:</strong> Align your schemas so that the join columns use the same type. If you cannot change the schema, create a computed column on the remote server with the appropriate type and use it in the join.</p>
<h3 id="heading-ignoring-collation-and-type-mismatches">Ignoring collation and type mismatches</h3>
<p><strong>Anti‑pattern:</strong></p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">SELECT</span> *
<span class="hljs-keyword">FROM</span> remote_table
<span class="hljs-keyword">WHERE</span> citext_col = <span class="hljs-string">'abc'</span>;
</code></pre>
<p>If the remote server doesn’t have the citext extension installed, the comparison semantics will differ, and the FDW will refuse to ship the filter. This appears harmless until you see the plan and realize all rows were fetched.</p>
<p><strong>Better:</strong> Install the same extensions and collations on the remote server, or convert the column to a base type like text on both sides.</p>
<h2 id="heading-extending-tuning-calibrating-cost-models">Extending Tuning: Calibrating Cost Models</h2>
<p>Earlier, we discussed <code>fetch_size</code>, <code>use_remote_estimate</code>, and the cost knobs. This section expands on how to use them strategically.</p>
<h3 id="heading-balancing-fetch-size-and-memory">Balancing fetch size and memory</h3>
<p><code>fetch_size</code> controls how many rows the FDW asks for in each round trip <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=">[9]</a>. Think of it as the batch size. The default (100) works well for small result sets. If you expect to retrieve tens of thousands of rows, a higher fetch size reduces the overhead of many network requests. But there are trade‑offs:</p>
<ul>
<li><p><strong>Memory consumption:</strong> Each foreign scan buffers rows until they are consumed. A huge fetch size (for example, 10,000) may allocate more memory than you expect, especially when multiple scans run concurrently. Monitor memory usage as you increase this setting.</p>
</li>
<li><p><strong>Latency hiding:</strong> If network latency is high, overlapping network requests with local processing can hide some latency. But <code>postgres_fdw</code> does not pipeline multiple fetches – it waits for one batch before requesting the next. This means that a larger batch size reduces the number of waits, but cannot overlap them. If you operate across data centers, consider using a connection pooler or caching layer instead of just increasing fetch_size.</p>
</li>
</ul>
<h3 id="heading-remote-estimates-vs-local-estimates">Remote estimates vs. local estimates</h3>
<p>The planner uses statistics to estimate how many rows each node will produce, which in turn influences join order. When <code>use_remote_estimate</code> is false (the default), the planner guesses based on local stats collected by <code>ANALYZE</code> on the foreign table. This can be wrong if the remote table has a different distribution than the local sample, or if the table has changed since the last <code>ANALYZE</code>.</p>
<p>Setting <code>use_remote_estimate</code> to true instructs the FDW to run <code>EXPLAIN</code> on the remote server during planning to obtain row counts and cost estimates <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=This%20option%2C%20which%20can%20be,false">[3]</a>. This can improve join ordering, especially when joining multiple foreign tables or mixing local and foreign tables. The downside is increased planning time because each remote estimate runs an extra query.</p>
<p>In practice:</p>
<ul>
<li><p>Enable <code>use_remote_estimate</code> on queries with complex joins where the planner picks obviously wrong join orders. If enabling it improves the plan, consider leaving it on for that server or table.</p>
</li>
<li><p>Use <code>ANALYZE</code> on foreign tables periodically if your remote data is relatively static. This populates local stats and can avoid the overhead of remote estimates.</p>
</li>
<li><p>Don’t enable <code>use_remote_estimate</code> indiscriminately on simple lookups. The cost of additional round-trip remote flights may outweigh the benefit.</p>
</li>
</ul>
<h3 id="heading-tuning-cost-parameters">Tuning cost parameters</h3>
<p><code>fdw_startup_cost</code> and <code>fdw_tuple_cost</code> control how much the planner thinks it costs to start a foreign scan and fetch each row <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=This%20option%2C%20which%20can%20be,false">[3]</a>. If these are too low, the planner may choose a nested loop that generates many small remote calls. If they are too high, the planner might avoid remote scans even when they are efficient.</p>
<p>You can adjust these parameters based on empirical measurement:</p>
<ul>
<li><p>Increase <code>fdw_startup_cost</code> to discourage the planner from using nested loops that call the remote table repeatedly. You might set it to the average cost of a round-trip remote.</p>
</li>
<li><p>Increase <code>fdw_tuple_cost</code> if network bandwidth is limited or expensive. This indicates to the planner that each remote row incurs higher fetch costs than a local row. The planner will prefer plans that filter early on the remote side.</p>
</li>
</ul>
<p>Always adjust these settings gradually and observe the effect on the plan. Keep separate settings per foreign server if network conditions differ.</p>
<h3 id="heading-when-to-analyze-foreign-tables">When to analyze foreign tables</h3>
<p>Running <code>ANALYZE</code> on a foreign table collects sample statistics by pulling a subset of rows from the remote server. This helps the planner estimate row counts when <code>use_remote_estimate</code> is off. It also helps decide whether to use an index on the remote side. You should analyze foreign tables when:</p>
<ul>
<li><p>The remote table is large and static, and you want accurate local estimates without the overhead of remote estimates.</p>
</li>
<li><p>You have just defined a foreign table, and the default stats are empty.</p>
</li>
<li><p>You changed the extensions allow‑list to enable more pushdown and want the planner to see the effect.</p>
</li>
</ul>
<p>Conversely, if the remote data changes constantly, <code>ANALYZE</code> results will quickly become stale. In that case, rely on use_remote_estimate instead.</p>
<h2 id="heading-further-case-studies-and-practical-examples">Further Case Studies and Practical Examples</h2>
<p>The Keycloak coverage example is not the only place where pushdown matters. The following scenarios illustrate other patterns you may encounter.</p>
<h3 id="heading-reporting-on-a-sharded-logging-system">Reporting on a sharded logging system</h3>
<p>Imagine you store application logs across multiple shards, each a separate Postgres database. You want to produce a report of the number of error logs per service per day.</p>
<p>A naïve approach might join all shards in one query:</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">SELECT</span> shard, service, date_trunc(<span class="hljs-string">'day'</span>, log_time) <span class="hljs-keyword">AS</span> day, COUNT(*)
<span class="hljs-keyword">FROM</span> shard1.logs
<span class="hljs-keyword">UNION</span> <span class="hljs-keyword">ALL</span>
<span class="hljs-keyword">SELECT</span> shard, service, date_trunc(<span class="hljs-string">'day'</span>, log_time) <span class="hljs-keyword">AS</span> day, COUNT(*)
<span class="hljs-keyword">FROM</span> shard2.logs
...;
</code></pre>
<p>This approach will fetch all log rows to the local server and aggregate them locally. A better solution is to push the grouping to each shard:</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">SELECT</span> shard, service, day, sum(count)
<span class="hljs-keyword">FROM</span> (
  <span class="hljs-keyword">SELECT</span> <span class="hljs-number">1</span> <span class="hljs-keyword">AS</span> shard, service, date_trunc(<span class="hljs-string">'day'</span>, log_time) <span class="hljs-keyword">AS</span> day, COUNT(*) <span class="hljs-keyword">AS</span> count
  <span class="hljs-keyword">FROM</span> shard1.logs
  <span class="hljs-keyword">WHERE</span> log_time &gt;= <span class="hljs-meta">$1</span> <span class="hljs-keyword">AND</span> log_time &lt; <span class="hljs-meta">$2</span>
  <span class="hljs-keyword">GROUP</span> <span class="hljs-keyword">BY</span> service, day
  <span class="hljs-keyword">UNION</span> <span class="hljs-keyword">ALL</span>
  <span class="hljs-keyword">SELECT</span> <span class="hljs-number">2</span> <span class="hljs-keyword">AS</span> shard, service, date_trunc(<span class="hljs-string">'day'</span>, log_time) <span class="hljs-keyword">AS</span> day, COUNT(*)
  <span class="hljs-keyword">FROM</span> shard2.logs
  <span class="hljs-keyword">WHERE</span> log_time &gt;= <span class="hljs-meta">$1</span> <span class="hljs-keyword">AND</span> log_time &lt; <span class="hljs-meta">$2</span>
  <span class="hljs-keyword">GROUP</span> <span class="hljs-keyword">BY</span> service, day
  ...
) x
<span class="hljs-keyword">GROUP</span> <span class="hljs-keyword">BY</span> shard, service, day;
</code></pre>
<p>Here, each foreign server returns a small set of aggregated rows instead of raw logs. The outer aggregation sums across shards. This pattern generalizes: push grouping and filtering to the remote side, then combine locally.</p>
<h3 id="heading-combining-remote-and-local-data-for-analytics">Combining remote and local data for analytics</h3>
<p>Suppose you have a local table <code>users</code> and a remote table <code>orders</code>. You want to compute the average order amount per user segment. A naïve query might look like:</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">SELECT</span> u.segment, AVG(o.amount)
<span class="hljs-keyword">FROM</span> users u
<span class="hljs-keyword">JOIN</span> orders o <span class="hljs-keyword">ON</span> o.user_id = u.id
<span class="hljs-keyword">GROUP</span> <span class="hljs-keyword">BY</span> u.segment;
</code></pre>
<p>This is a local join driving a remote nested loop. The better approach is to aggregate orders remotely by user_id and join on the small result:</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">WITH</span> remote_totals <span class="hljs-keyword">AS</span> (
  <span class="hljs-keyword">SELECT</span> user_id, SUM(amount) <span class="hljs-keyword">AS</span> total, COUNT(*) <span class="hljs-keyword">AS</span> n
  <span class="hljs-keyword">FROM</span> orders
  <span class="hljs-keyword">GROUP</span> <span class="hljs-keyword">BY</span> user_id
)
<span class="hljs-keyword">SELECT</span> u.segment, AVG(rt.total / rt.n)
<span class="hljs-keyword">FROM</span> users u
<span class="hljs-keyword">JOIN</span> remote_totals rt <span class="hljs-keyword">ON</span> u.id = rt.user_id
<span class="hljs-keyword">GROUP</span> <span class="hljs-keyword">BY</span> u.segment;
</code></pre>
<p>This pushes the heavy aggregation to the remote and transfers only one row per user. The local join then groups by segment. As with other examples, the key is to reduce remote rows before they cross the network.</p>
<h3 id="heading-avoiding-pushdown-for-correctness">Avoiding pushdown for correctness</h3>
<p>There are legitimate cases where you should <em>prevent</em> pushdown because of semantic differences. Postgres allows you to do this by adding <code>OFFSET 0</code> or wrapping the foreign table in a CTE.</p>
<p>For example, if a built‑in function behaves differently on the remote due to a version mismatch, you can force local evaluation:</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">WITH</span> local_eval <span class="hljs-keyword">AS</span> (<span class="hljs-keyword">SELECT</span>  <span class="hljs-keyword">FROM</span> remote_table)  <span class="hljs-comment">-- CTE prevents pushdown</span>
<span class="hljs-keyword">SELECT</span> 
<span class="hljs-keyword">FROM</span> local_eval
<span class="hljs-keyword">WHERE</span> some_complex_expression(local_eval.col) &gt; <span class="hljs-number">0</span>;
</code></pre>
<p>Alternatively, a <code>WHERE</code> clause like <code>random() &lt; 0.1</code> will not push down because <code>random()</code> is volatile – you don't need to force it. But adding <code>OFFSET 0</code> is a simple hack that prevents any pushdown:</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">SELECT</span> * <span class="hljs-keyword">FROM</span> remote_table <span class="hljs-keyword">OFFSET</span> <span class="hljs-number">0</span>;
</code></pre>
<p>Knowing how to disable pushdown intentionally helps you debug. If a query returns different results when pushdown occurs, suspect type/collation mismatches or remote session settings <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=In%20the%20remote%20sessions%20opened,their%20expected%20search%20path%20environment">[4]</a>.</p>
<h2 id="heading-monitoring-diagnostics-and-regression-testing">Monitoring, Diagnostics, and Regression Testing</h2>
<p>Monitoring doesn't end at counting remote rows. To make pushdown reliable in production, you need to set up mechanisms to detect regressions and gather evidence when performance changes.</p>
<h3 id="heading-automate-explain-regression-tests">Automate EXPLAIN regression tests</h3>
<p>In addition to unit tests and integration tests, you can add tests that assert the shape of your plans. For instance, if a mission‑critical report must always push down a <code>WHERE</code> clause, you can write a test that runs <code>EXPLAIN (VERBOSE)</code> and checks that the Remote SQL contains the filter. You might even parse loops and assert that it is 1. When a developer inadvertently adds a non‑immutable function or changes a join, the test will fail. This is akin to snapshot testing for SQL.</p>
<h3 id="heading-monitor-pgstatstatements-across-servers">Monitor pg_stat_statements across servers</h3>
<p>Enable <code>pg_stat_statements</code> on both the local and remote servers. On the local side, track the total time, planning time, and rows for each FDW query. On the remote side, track which queries are being executed.</p>
<p>Look for outliers: a query whose remote calls spike or whose average remote rows jump from hundreds to thousands. Those are early signs of pushdown failure.</p>
<h3 id="heading-log-remote-sql-with-autoexplain">Log remote SQL with auto_explain</h3>
<p>Setting <code>auto_explain.log_min_duration_statement</code> (for example, to 500ms) causes Postgres to automatically log slow queries with their plans. Combine this with <code>auto_explain.log_verbose = true</code> and <code>auto_explain.log_nested_statements = true</code> to capture remote SQL as well. When a federated query slows down, the log will show you exactly what remote SQL was executed and how often. This is invaluable in production, where you cannot always run EXPLAIN interactively.</p>
<h3 id="heading-use-connection-pooling-and-prepare-statements">Use connection pooling and prepare statements</h3>
<p><code>postgres_fdw</code> maintains a connection pool keyed on the user mapping. It reuses connections between queries, but you can also use connection pooling at the network level (for example, pgbouncer or pgcat).</p>
<p>Keeping connections warm reduces the startup cost, as captured by <code>fdw_startup_cost</code>. Meanwhile, preparing statements on the remote server (via <code>PREPARE</code> and <code>EXECUTE</code>) can save parse time when the same remote SQL is executed frequently. <code>postgres_fdw</code> can use server‑side prepared statements for parameterized scans.</p>
<h3 id="heading-regression-testing-after-version-upgrades">Regression testing after version upgrades</h3>
<p>Every major Postgres release brings improvements to postgres_fdw pushdown semantics. But new releases also change planner heuristics and remote SQL generation. After an upgrade, rerun your key queries with EXPLAIN (VERBOSE), compare the Remote SQL, and benchmark them.</p>
<p>In some cases, a release may push down something previously local, revealing a latent type mismatch or a function difference. In other cases, pushdown may be withheld due to a new rule. Don’t assume that an upgrade automatically improves performance – test it.</p>
<h2 id="heading-extended-guidelines-for-advanced-dbas">Extended Guidelines for Advanced DBAs</h2>
<p>To close this handbook, here are consolidated guidelines distilled from the previous sections. They go beyond simple bullet points to capture nuances. Keep them handy for reference or print them out for your team.</p>
<ol>
<li><p><strong>Respect the FDW safety model.</strong> Immutable functions and built‑in operators are your friends. Anything outside that scope must be explicitly allowed or evaluated locally. Understand which items belong to each category and plan accordingly.</p>
</li>
<li><p><strong>Always read the Remote SQL.</strong> Don’t trust your intuition about what is being pushed down. The Remote SQL string is the only source of truth. It indicates whether a predicate, join, sort, or limit operation is occurring remotely. It also shows parameter placeholders (for example, $1) that correspond to values passed from the local plan.</p>
</li>
<li><p><strong>Reduce before you fetch.</strong> The network is the highest cost. If the remote can reduce rows through filtering, grouping, or limiting, let it. If it cannot, structure your query to enable it. Avoid queries that require pulling large raw tables and processing them locally.</p>
</li>
<li><p><strong>Beware of join order.</strong> The planner sometimes chooses a nested loop with a foreign table as the inner side, resulting in repeated remote calls. Examine loops: if you see a high number, consider rewriting the query or adjusting cost parameters.</p>
</li>
<li><p><strong>Use CTEs strategically.</strong> A CTE can isolate remote scans and let you control whether they are materialized once or inlined. Use <code>MATERIALIZED</code> to avoid repeated remote scans when a CTE is referenced multiple times. Use <code>NOT MATERIALIZED</code> to allow optimizations across CTE boundaries.</p>
</li>
<li><p><strong>Instrument, monitor, iterate.</strong> Good FDW performance is not a one‑off fix. Monitor queries and plans. Use tests to catch regressions. Adjust tuning knobs and indexes as your data or workload changes. Document your reasoning so others can understand why a particular plan is expected.</p>
</li>
<li><p><strong>Educate your team.</strong> Federated queries invite subtle bugs and performance traps. Share the high‑level rules – immutable functions only, cross‑server joins are local, always check remote SQL – so engineers write safer queries by default. A 30‑minute training can save hours of debugging later.</p>
</li>
</ol>
<h2 id="heading-bringing-it-all-together">Bringing it All Together</h2>
<p>This handbook has covered a lot of ground: from the high‑level principle that pushdown is about data movement, to the nitty‑gritty of join conditions and tuning knobs, to troubleshooting steps and case studies. It is intentionally opinionated and personal: these are the patterns and pitfalls encountered in real systems, not abstract guidelines. By sharing specific examples, I hoped to make the rules memorable and show how they interplay with actual workloads.</p>
<p>The goal is not just to tell you what to do, but to show you how to think and problem solve: review the plan, trace data movement, and determine whether the query is doing the heavy work in the right place.</p>
<p>That thinking process, practiced enough times, becomes second nature. When you write a new query, you'll automatically consider whether your predicates are immutable, whether the join can be shipped, and whether you are about to trigger an N+1 pattern. When you review plans, you'll start from the Foreign Scan nodes and remote SQL, not the top‑level node. When you tune, you'll know which knobs to twist and in which order.</p>
<p>Keep experimenting. Use the examples here as starting points. Try different structures in a test environment and measure the difference. The more you play with pushdown, the more comfortable you'll become with its constraints and superpowers.</p>
<p>If this handbook helps you avoid one performance incident or saves you from shipping a broken query, it has done its job. Enjoy exploring the federated world of Postgres.</p>
<h2 id="heading-references">References</h2>
<p><a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=It%20is%20generally%20recommended%20that,differently%20from%20the%20local%20server">[1]</a> <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=">[2]</a> <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=This%20option%2C%20which%20can%20be,false">[3]</a> <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=In%20the%20remote%20sessions%20opened,their%20expected%20search%20path%20environment">[4]</a> <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=functions%20in%20such%20clauses%20must,to%20reduce%20the%20risk%20of">[5]</a> <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=When%20,clauses">[6]</a> <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=">[9]</a> <a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html#:~:text=,Extension">[10]</a> PostgreSQL: Documentation: 18: F.38. postgres_fdw – access data stored in external PostgreSQL servers (<a target="_blank" href="https://www.postgresql.org/docs/current/postgres-fdw.html">https://www.postgresql.org/docs/current/postgres-fdw.html</a>)</p>
<p><a target="_blank" href="https://www.postgresql.org/docs/release/10.0/#:~:text=,Jeevan%20Chalke%2C%20Ashutosh%20Bapat">[7]</a> PostgreSQL: Release Notes (<a target="_blank" href="https://www.postgresql.org/docs/release/10.0/">https://www.postgresql.org/docs/release/10.0/</a>)</p>
<p><a target="_blank" href="https://www.postgresql.org/docs/release/12.0/#:~:text=,Etsuro%20Fujita%29%20%C2%A7%20%C2%A7">[8]</a> PostgreSQL: Release Notes (<a target="_blank" href="https://www.postgresql.org/docs/release/12.0/">https://www.postgresql.org/docs/release/12.0/</a>)</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Learn Relational Database Design ]]>
                </title>
                <description>
                    <![CDATA[ Relational databases are used in many different types of software. We just posted a course on the freeCodeCamp.org YouTube channel that will help you learn relational database design from the ground up. This course covers SQL fundamentals, entity-rel... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/learn-relational-database-design-1/</link>
                <guid isPermaLink="false">697bd350c5c536590f03089b</guid>
                
                    <category>
                        <![CDATA[ Databases ]]>
                    </category>
                
                    <category>
                        <![CDATA[ youtube ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Beau Carnes ]]>
                </dc:creator>
                <pubDate>Thu, 29 Jan 2026 21:38:24 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1769722691702/9d54eabe-0c4c-43d9-b584-a34c61fbe43d.jpeg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Relational databases are used in many different types of software.</p>
<p>We just posted a course on the freeCodeCamp.org YouTube channel that will help you learn relational database design from the ground up. This course covers SQL fundamentals, entity-relationship modeling, normalization (1NF through BCNF), data types and constraints, indexing strategies, and query optimization.</p>
<p>This course is based on the book Grokking Relational Database Design by Dr. Qiang Hao and Dr. Michael Tsikerdekis (Manning Publications, 2025).</p>
<p>Here are the sections in the course:</p>
<ul>
<li><p>Relational Databases for Beginners — Tables, Entities, Keys &amp; SQL</p>
</li>
<li><p>SQL Filtering &amp; Aggregation</p>
</li>
<li><p>SQL Table Commands</p>
</li>
<li><p>Foreign Keys in SQL</p>
</li>
<li><p>How SQL JOINs Work</p>
</li>
<li><p>How to learn SQL on your own</p>
</li>
<li><p>Database Design Goals</p>
</li>
<li><p>Database Design Lifecycle</p>
</li>
<li><p>From Real-World Ideas to Tables</p>
</li>
<li><p>Primary Key, Candidate Key, and Super Key</p>
</li>
<li><p>Don't Use the Wrong SQL String Type</p>
</li>
<li><p>The FLOAT Mistake That Crashed a Stock Exchange</p>
</li>
<li><p>SQL Date and Time Types Explained</p>
</li>
<li><p>Connecting Entities in an ER Diagram</p>
</li>
<li><p>One-to-One Relationships</p>
</li>
<li><p>One-to-Many Relationships</p>
</li>
<li><p>Many-to-Many Relationships</p>
</li>
<li><p>Strong vs Weak Entities</p>
</li>
<li><p>First Normal Form - Primary Keys and Atomic Values</p>
</li>
<li><p>Second Normal Form - Partial Keys and Functional Dependencies</p>
</li>
<li><p>Third Normal Form - Transitive Dependencies</p>
</li>
<li><p>The Untold Story of BCNF</p>
</li>
<li><p>Primary Key vs Unique Constraints</p>
</li>
<li><p>Foreign Key Constraints - ON DELETE &amp; ON UPDATE</p>
</li>
<li><p>Other Constraints: NOT NULL, DEFAULT, and CHECK</p>
</li>
<li><p>Access Control, Hashing &amp; Encryption</p>
</li>
<li><p>B-Tree vs Full-Text Indexes</p>
</li>
<li><p>Denormalization</p>
</li>
</ul>
<p>Watch the full course on <a target="_blank" href="https://youtu.be/26ls5lNiijk">the freeCodeCamp.org YouTube channel</a> (6-hour watch).</p>
<div class="embed-wrapper">
        <iframe width="560" height="315" src="https://www.youtube.com/embed/26ls5lNiijk" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Manage Blue-Green Deployments on AWS ECS with Database Migrations: Complete Implementation Guide ]]>
                </title>
                <description>
                    <![CDATA[ Blue-green deployments are celebrated for enabling zero-downtime releases and instant rollbacks. You deploy your new version (green) alongside the current one (blue), switch traffic over, and if something goes wrong, you switch back. Simple, right? N... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-manage-blue-green-deployments-on-aws-ecs-with-database-migrations/</link>
                <guid isPermaLink="false">69693109596ef11a775126fb</guid>
                
                    <category>
                        <![CDATA[ deployment ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Blue/Green deployment ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AWS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Databases ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Destiny Erhabor ]]>
                </dc:creator>
                <pubDate>Thu, 15 Jan 2026 18:25:13 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1768497873258/be1ce2a3-c95f-488e-913a-a772007a0d2a.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Blue-green deployments are celebrated for enabling zero-downtime releases and instant rollbacks. You deploy your new version (green) alongside the current one (blue), switch traffic over, and if something goes wrong, you switch back. Simple, right?</p>
<p>Not quite. While blue-green deployments work beautifully for stateless applications, they become significantly more complex when you introduce databases and stateful services into the equation. The moment your blue and green environments need to share a database, you're facing a fundamental challenge: how do you evolve your schema and data without breaking either version?</p>
<p>In this article, we'll tackle the real-world complexities of implementing blue-green deployments on Amazon ECS when your application depends on shared state. You'll learn practical strategies for handling database migrations, managing sessions, and maintaining data consistency across application versions.</p>
<p>💡 <strong>Complete Working Example</strong>: All code examples in this article are available in the <a target="_blank" href="https://github.com/Caesarsage/bluegreen-deployment-ecs">bluegreen-deployment-ecs</a> <a target="_blank" href="https://github.com/Caesarsage/bluegreen-deployment-ecs">repository on GitHub.</a> You can clone it and deploy the entire infrastructure to your AWS account.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-the-problem-with-state-in-blue-green-deployments">The Problem with State in Blue-Green Deployments</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-database-migration-strategies-for-blue-green">Database Migration Strategies for Blue-Green</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-handling-stateful-services-in-ecs">Handling Stateful Services in ECS</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-complete-implementation-end-to-end-example">Complete Implementation: End-to-End Example</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-rollback-strategies">Rollback Strategies</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-monitoring-during-deployments">Monitoring During Deployments</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-best-practices">Best Practices</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-when-not-to-use-blue-green">When NOT to Use Blue-Green</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-alternative-deployment-strategies">Alternative Deployment Strategies</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-cleanup">Cleanup</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-further-resources">Further Resources</a></p>
</li>
</ul>
<h2 id="heading-the-problem-with-state-in-blue-green-deployments">The Problem with State in Blue-Green Deployments</h2>
<p>The elegance of blue-green deployments starts to crumble when you consider databases. Here's why: your blue environment runs application version 1, your green environment runs version 2, but they both connect to the same RDS instance.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768056130585/109ceff8-4500-45d7-aaa0-5e259b4a7b11.png" alt="Figure 1: The blue-green dilemma - both environments share the same database but expect different schemas" class="image--center mx-auto" width="1579" height="1131" loading="lazy"></p>
<p>Consider this scenario: you're adding a new feature that requires a new database column. Version 2 of your application expects this column to exist. You deploy green, run your migration to add the column, and switch traffic.</p>
<p>Everything works great until you need to rollback. Now version 1 is receiving traffic, but it doesn't know what to do with that new column. Worse, if your migration removed or renamed a column that version 1 depends on, your rollback will fail catastrophically.</p>
<p>Here are the specific challenges you'll face:</p>
<ul>
<li><p><strong>Schema versioning conflicts</strong>: Your blue environment expects schema version N, while green expects version N+1. Any breaking schema change will cause one environment to fail.</p>
</li>
<li><p><strong>Data inconsistencies</strong>: If version 2 writes data in a new format that version 1 can't read, switching back to blue will result in errors or data corruption.</p>
</li>
<li><p><strong>Irreversible migrations</strong>: Some database changes are inherently destructive. Dropping a column, changing data types, or restructuring tables can't be easily undone.</p>
</li>
<li><p><strong>Failed rollbacks</strong>: The promise of instant rollback becomes hollow when your database has evolved beyond what the blue environment can handle.</p>
</li>
</ul>
<p>Let's explore the strategies that solve these problems.</p>
<h2 id="heading-database-migration-strategies-for-blue-green">Database Migration Strategies for Blue-Green</h2>
<h3 id="heading-strategy-1-the-expand-contract-pattern-recommended">Strategy 1: The Expand-Contract Pattern (Recommended)</h3>
<p>The expand-contract pattern is the most practical approach for blue-green deployments with shared databases. It works by breaking schema changes into three phases, ensuring backwards compatibility throughout.</p>
<h4 id="heading-phase-1-expand">Phase 1: Expand</h4>
<p>In this phase, you add new schema elements while keeping old ones intact. If you're renaming a column, add the new column without removing the old one. If you're changing table structure, create new tables alongside existing ones.</p>
<pre><code class="lang-sql"><span class="hljs-comment">-- Example: Renaming 'user_name' to 'username'</span>
<span class="hljs-comment">-- Phase 1: Expand - Add new column</span>
<span class="hljs-keyword">ALTER</span> <span class="hljs-keyword">TABLE</span> <span class="hljs-keyword">users</span> <span class="hljs-keyword">ADD</span> <span class="hljs-keyword">COLUMN</span> username <span class="hljs-built_in">VARCHAR</span>(<span class="hljs-number">255</span>);

<span class="hljs-comment">-- Populate new column from old column</span>
<span class="hljs-keyword">UPDATE</span> <span class="hljs-keyword">users</span> <span class="hljs-keyword">SET</span> username = user_name <span class="hljs-keyword">WHERE</span> username <span class="hljs-keyword">IS</span> <span class="hljs-literal">NULL</span>;
</code></pre>
<p>At this point, your database supports both the old schema (used by blue) and the new schema (used by green). Your application code needs to handle both as well.</p>
<h4 id="heading-phase-2-deploy">Phase 2: Deploy</h4>
<p>Now, deploy your green environment with code that uses the new schema. But this code should still write to both old and new columns to maintain compatibility.</p>
<pre><code class="lang-python"><span class="hljs-comment"># Version 2 code - writes to both columns</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">update_user</span>(<span class="hljs-params">user_id, username</span>):</span>
    db.execute(
        <span class="hljs-string">"UPDATE users SET username = %s, user_name = %s WHERE id = %s"</span>,
        (username, username, user_id)
    )
</code></pre>
<p>Traffic shifts from blue to green. Both environments work because the database supports both schemas. If you need to rollback, blue still functions perfectly because the old columns are intact.</p>
<h4 id="heading-phase-3-contract">Phase 3: Contract</h4>
<p>After you're confident green is stable and you've decommissioned blue, remove the old schema elements in a separate deployment.</p>
<pre><code class="lang-sql"><span class="hljs-comment">-- Phase 3: Contract - Remove old column</span>
<span class="hljs-keyword">ALTER</span> <span class="hljs-keyword">TABLE</span> <span class="hljs-keyword">users</span> <span class="hljs-keyword">DROP</span> <span class="hljs-keyword">COLUMN</span> user_name;
</code></pre>
<p>Update your application code to stop writing to the old columns. This is now version 3, deployed as a standard release.</p>
<p><strong>When to use</strong>: This should be your default approach for most schema changes including adding/removing columns, renaming fields, changing constraints, and restructuring tables.</p>
<h3 id="heading-strategy-2-parallel-schemas-or-databases">Strategy 2: Parallel Schemas or Databases</h3>
<p>For major breaking changes where backwards compatibility is impractical, you might maintain entirely separate database versions. Version 1 connects to database A, version 2 connects to database B. This approach requires data synchronization between databases. AWS Database Migration Service (DMS) can replicate data in near real-time, or you can build custom replication logic using change data capture.</p>
<pre><code class="lang-python"><span class="hljs-comment"># Configuration for version-specific database connections</span>
DATABASE_CONFIG = {
    <span class="hljs-string">'v1'</span>: {
        <span class="hljs-string">'host'</span>: <span class="hljs-string">'blue-db.cluster-xxxxx.us-east-1.rds.amazonaws.com'</span>,
        <span class="hljs-string">'database'</span>: <span class="hljs-string">'app_v1'</span>
    },
    <span class="hljs-string">'v2'</span>: {
        <span class="hljs-string">'host'</span>: <span class="hljs-string">'green-db.cluster-yyyyy.us-east-1.rds.amazonaws.com'</span>,
        <span class="hljs-string">'database'</span>: <span class="hljs-string">'app_v2'</span>
    }
}
</code></pre>
<p>During the transition period, you run DMS to keep both databases synchronized, with the understanding that writes go to the active version's database.</p>
<p>The challenge is that you're now managing data synchronization, dealing with replication lag, and paying for two databases. Eventually, you need to consolidate back to one database, which requires another migration. This is expensive and complex, which is why it's the "nuclear option."</p>
<p><strong>When to use</strong>: Only for major architectural changes, complete data model redesigns, or when migrating between database types (for example, MySQL to PostgreSQL). If expand-contract can possibly work, use that instead.</p>
<h3 id="heading-strategy-3-feature-flags-for-gradual-rollout">Strategy 3: Feature Flags for Gradual Rollout</h3>
<p>Feature flags allow you to decouple deployment from release. Both blue and green run the same codebase, but features are toggled on or off via configuration. This shifts the problem from schema compatibility to code-level compatibility.</p>
<pre><code class="lang-python"><span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">create_user</span>(<span class="hljs-params">user_data</span>):</span>
    config = get_feature_config()
    <span class="hljs-keyword">if</span> config[<span class="hljs-string">'use_new_user_schema'</span>]:
        <span class="hljs-keyword">return</span> create_user_v2(user_data)
    <span class="hljs-keyword">else</span>:
        <span class="hljs-keyword">return</span> create_user_v1(user_data)
</code></pre>
<p>Instead of having two separate deployments (blue and green), you have ONE deployment with conditional logic. The "switch" from old to new behavior happens via configuration change, not infrastructure change. This is technically not pure blue-green, but it's a powerful hybrid approach.</p>
<h4 id="heading-how-it-works">How it works</h4>
<p>Your application checks AWS AppConfig (or similar service) for feature flags before executing code paths. When a flag is off, it uses the old schema/logic. When on, it uses the new schema/logic. You can even enable features for a percentage of users (5% get new behavior, 95% get old behavior) for gradual rollout.</p>
<p>The tradeoff is that your codebase temporarily contains both old and new logic with conditional branches everywhere. This increases complexity and requires disciplined cleanup after the feature is fully released. However, you gain fine-grained control and can toggle features on/off instantly without deploying new infrastructure.</p>
<p><strong>When to use:</strong> For large features with uncertain stability, gradual rollouts to monitor impact, or when you want instant rollback capability without touching infrastructure. Also useful when combined with expand-contract for extra safety.</p>
<h2 id="heading-handling-stateful-services-in-ecs">Handling Stateful Services in ECS</h2>
<p>Beyond databases, several other stateful components require careful consideration during blue-green deployments.</p>
<h3 id="heading-session-management">Session Management</h3>
<p>It’s a good idea to store sessions in ElastiCache or DynamoDB rather than application memory:</p>
<pre><code class="lang-python">app.config[<span class="hljs-string">'SESSION_TYPE'</span>] = <span class="hljs-string">'dynamodb'</span>
app.config[<span class="hljs-string">'SESSION_DYNAMODB'</span>] = boto3.client(<span class="hljs-string">'dynamodb'</span>)
</code></pre>
<h3 id="heading-shared-resources">Shared Resources</h3>
<p>Beyond database sessions, your application likely depends on other stateful components that need coordination during blue-green deployments:</p>
<h4 id="heading-1-s3-buckets">1. S3 buckets</h4>
<p>If your application stores files or data in S3, schema changes to object metadata or file formats can cause compatibility issues between versions. To address this, you can enable S3 versioning to maintain multiple format versions simultaneously.</p>
<p>For example, if version 2 writes JSON files with a new structure, version 1 should still be able to read the old format. You can include a version prefix in object keys (like <code>v1/user-data.json</code> and <code>v2/user-data.json</code>) or embed version metadata in the objects themselves.</p>
<h4 id="heading-message-queues-sqssns">Message queues (SQS/SNS)</h4>
<p>Messages sent by one version must be readable by the other during the transition. You can use versioned message schemas with a <code>schema_version</code> field in your message payload. Both blue and green should be able to parse messages from either version, even if they only produce messages in their preferred format. Consider using a schema registry or validation library to ensure compatibility.</p>
<h4 id="heading-cache-layers-elasticacheredis">Cache layers (ElastiCache/Redis)</h4>
<p>Cached data structure changes can cause deserialization errors when switching between versions. Try versioning your cache keys by including the schema version: <code>CACHE_VERSION = 'v2'</code> and then <code>cache_key = f"user:{CACHE_VERSION}:{user_id}"</code>. This ensures blue and green maintain separate cache namespaces, preventing cross-contamination. When you fully migrate to green, you can flush the old cache keys or let them expire naturally.</p>
<pre><code class="lang-python">CACHE_VERSION = <span class="hljs-string">'v2'</span>
cache_key = <span class="hljs-string">f"user:<span class="hljs-subst">{CACHE_VERSION}</span>:<span class="hljs-subst">{user_id}</span>"</span>
</code></pre>
<h2 id="heading-implementation-end-to-end-example">Implementation: End-to-End Example</h2>
<p>Let's walk through a complete blue-green deployment with ECS, handling a database schema change using the <strong>expand-contract pattern</strong>. We'll migrate from a single <code>address</code> text field to structured <code>street_address</code>, <code>city</code>, <code>state</code>, and <code>zip_code</code> fields.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768052075044/fdb732dd-cf3d-473f-a22c-f5ab98870625.png" alt="Figure 2: The three phases of expand-contract migration ensuring continuous compatibility" class="image--center mx-auto" width="3444" height="624" loading="lazy"></p>
<p><strong>Here’s the scenario:</strong> You're running an e-commerce application on ECS. The current version (blue) stores customer addresses in a single address text field. Version 2 (green) splits this into structured fields: street_address, city, state, and zip_code.</p>
<h3 id="heading-architecture-setup"><strong>Architecture Setup</strong></h3>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768087707691/ff19ce97-b745-4aa8-8b39-4d835fd781cd.png" alt="Figure 3: Complete AWS architecture for blue-green ECS deployment with shared RDS database" class="image--center mx-auto" width="2479" height="3679" loading="lazy"></p>
<p>Your infrastructure includes:</p>
<ul>
<li><p>ECS cluster running Fargate tasks</p>
</li>
<li><p>Application Load Balancer with two target groups (blue and green)</p>
</li>
<li><p>RDS PostgreSQL database (shared between environments)</p>
</li>
<li><p>CodeDeploy for managing traffic shifts</p>
</li>
<li><p>Parameter Store for database connection strings</p>
</li>
</ul>
<p>💡 <strong>Implementation Note</strong>: The complete Terraform code for this architecture is available in the <a target="_blank" href="https://github.com/Caesarsage/bluegreen-deployment-ecs/tree/main/terraform">companion GitHub repository</a>.</p>
<h3 id="heading-prerequisites">Prerequisites</h3>
<p>Before starting, make sure that you have the following tools installed and your AWS credentials properly configured:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Required tools</span>
aws --version      <span class="hljs-comment"># AWS CLI</span>
terraform --version <span class="hljs-comment"># Terraform &gt;= 1.0</span>
docker --version   <span class="hljs-comment"># Docker</span>
psql --version     <span class="hljs-comment"># PostgreSQL client</span>

<span class="hljs-comment"># Configure AWS credentials</span>
aws configure
aws sts get-caller-identity  <span class="hljs-comment"># Verify your identity</span>
</code></pre>
<h3 id="heading-step-1-deploy-infrastructure-and-blue-environment">Step 1: Deploy Infrastructure and Blue Environment</h3>
<p>We’ll start by setting up the entire AWS infrastructure from scratch using Terraform, then deploying the initial version of our application (blue environment).</p>
<p>First, clone the repository and set up your environment:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Clone the repository</span>
git <span class="hljs-built_in">clone</span> https://github.com/Caesarsage/bluegreen-deployment-ecs.git
<span class="hljs-built_in">cd</span> bluegreen-deployment-ecs

<span class="hljs-comment"># Create terraform variables</span>
<span class="hljs-built_in">cd</span> terraform
cat &gt; terraform.tfvars &lt;&lt;EOF
aws_region         = <span class="hljs-string">"us-east-1"</span>
project_name       = <span class="hljs-string">"ecommerce-bluegreen"</span>
environment        = <span class="hljs-string">"production"</span>
vpc_cidr           = <span class="hljs-string">"10.0.0.0/16"</span>

<span class="hljs-comment"># Database credentials (CHANGE THESE!)</span>
db_username = <span class="hljs-string">"dbadmin"</span>
db_password = <span class="hljs-string">"ChangeThisPassword123!"</span>

<span class="hljs-comment"># Container configuration</span>
container_image = <span class="hljs-string">"PLACEHOLDER"</span>  <span class="hljs-comment"># Will update after building image</span>
container_port  = 8080

<span class="hljs-comment"># Scaling configuration</span>
desired_count = 2
cpu           = <span class="hljs-string">"256"</span>
memory        = <span class="hljs-string">"512"</span>

<span class="hljs-comment"># Notifications</span>
notification_email = <span class="hljs-string">"your-email@example.com"</span>
EOF
</code></pre>
<p><strong>Security Note:</strong> Never commit <code>terraform.tfvars</code> to Git. It's already in <code>.gitignore</code>.</p>
<p>Next, initialize Terraform and create the ECR repository:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Initialize Terraform</span>
terraform init
terraform validate

<span class="hljs-comment"># Create ECR repository</span>
terraform apply -target=aws_ecr_repository.app

<span class="hljs-comment"># Get ECR repository URL</span>
<span class="hljs-built_in">export</span> ECR_REPO=$(terraform output -raw ecr_repository_url)
<span class="hljs-built_in">echo</span> <span class="hljs-string">"ECR Repository: <span class="hljs-variable">$ECR_REPO</span>"</span>
</code></pre>
<p>We create the ECR repository first because we need somewhere to push our Docker image. Then we'll build the image, push it, and finally deploy the rest of the infrastructure that depends on that image existing.</p>
<p>Build and push the initial application like this:</p>
<pre><code class="lang-bash">
<span class="hljs-built_in">cd</span> ..  <span class="hljs-comment"># Back to project root</span>

<span class="hljs-comment"># Set variables</span>
<span class="hljs-built_in">export</span> AWS_REGION=us-east-1
<span class="hljs-built_in">export</span> AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
<span class="hljs-built_in">export</span> ECR_REPOSITORY=ecommerce-bluegreen
<span class="hljs-built_in">export</span> IMAGE_TAG=v1.0.0

<span class="hljs-comment"># Login to ECR</span>
aws ecr get-login-password --region <span class="hljs-variable">$AWS_REGION</span> | \
    docker login --username AWS --password-stdin <span class="hljs-variable">$AWS_ACCOUNT_ID</span>.dkr.ecr.<span class="hljs-variable">$AWS_REGION</span>.amazonaws.com

<span class="hljs-comment"># Build the image</span>
docker build --platform linux/amd64 -t <span class="hljs-variable">$ECR_REPOSITORY</span>:<span class="hljs-variable">$IMAGE_TAG</span> -f docker/Dockerfile .

<span class="hljs-comment"># Tag and push to ECR</span>
docker tag <span class="hljs-variable">$ECR_REPOSITORY</span>:<span class="hljs-variable">$IMAGE_TAG</span> \
    <span class="hljs-variable">$AWS_ACCOUNT_ID</span>.dkr.ecr.<span class="hljs-variable">$AWS_REGION</span>.amazonaws.com/<span class="hljs-variable">$ECR_REPOSITORY</span>:<span class="hljs-variable">$IMAGE_TAG</span>

docker push <span class="hljs-variable">$AWS_ACCOUNT_ID</span>.dkr.ecr.<span class="hljs-variable">$AWS_REGION</span>.amazonaws.com/<span class="hljs-variable">$ECR_REPOSITORY</span>:<span class="hljs-variable">$IMAGE_TAG</span>

<span class="hljs-comment"># Update terraform.tfvars with the image URL</span>
<span class="hljs-built_in">echo</span> <span class="hljs-string">"container_image = \"<span class="hljs-variable">$AWS_ACCOUNT_ID</span>.dkr.ecr.<span class="hljs-variable">$AWS_REGION</span>.amazonaws.com/<span class="hljs-variable">$ECR_REPOSITORY</span>:<span class="hljs-variable">$IMAGE_TAG</span>\""</span> &gt;&gt; terraform/terraform.tfvars
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768137809806/820d7005-b924-4224-9b58-de5701466c1f.png" alt="Figure 4: ECR Private repository for Docker image" class="image--center mx-auto" width="2442" height="632" loading="lazy"></p>
<p>The <a target="_blank" href="https://github.com/Caesarsage/bluegreen-deployment-ecs/tree/main/app">application code</a> is a Flask application that handles both old and new schema formats based on the <code>APP_VERSION</code> environment variable.</p>
<p>Now deploy the complete infrastructure:</p>
<pre><code class="lang-bash"><span class="hljs-built_in">cd</span> terraform
terraform apply  <span class="hljs-comment"># Takes ~15-20 minutes</span>

<span class="hljs-comment"># Get outputs</span>
<span class="hljs-built_in">export</span> ALB_URL=$(terraform output -raw alb_url)
<span class="hljs-built_in">export</span> TEST_URL=$(terraform output -raw test_url)
<span class="hljs-built_in">export</span> DB_ENDPOINT=$(terraform output -raw db_endpoint)
<span class="hljs-built_in">export</span> ECR_URL=$(terraform output -raw ecr_repository_url)
<span class="hljs-built_in">export</span> BASTION_IP=$(terraform output -raw bastion_public_ip)

<span class="hljs-built_in">echo</span> <span class="hljs-string">"Application URL: <span class="hljs-variable">$ALB_URL</span>"</span>
<span class="hljs-built_in">echo</span> <span class="hljs-string">"Test URL: <span class="hljs-variable">$TEST_URL</span>"</span>
<span class="hljs-built_in">echo</span> <span class="hljs-string">"Database Endpoint: <span class="hljs-variable">$DB_ENDPOINT</span>"</span>
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768141033921/07c2e9b9-c652-4cec-91ae-2de956d8655d.png" alt="Application Load Balancer with two target groups (blue and green)" class="image--center mx-auto" width="2504" height="844" loading="lazy"></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768142296716/9963c779-e0a8-4418-8d69-9bc8fcbbc553.png" alt="Figure 5: Application Load Balancer with two target groups (blue and green)" class="image--center mx-auto" width="2553" height="458" loading="lazy"></p>
<p>The production listener (port 80) is what your users hit. The test listener (port 8080) lets you test the green environment before shifting production traffic to it. This is crucial for validation.</p>
<p>You can see the complete Terraform configuration in <a target="_blank" href="https://github.com/Caesarsage/bluegreen-deployment-ecs/tree/main/terraform"><code>terraform</code></a>.</p>
<h3 id="heading-step-2-initialize-database-schema">Step 2: Initialize Database Schema</h3>
<p>Now you’ll need to initialize the database with the schema for version 1 (blue). We'll use Bastion for secure access:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Copy the migration files to the bastion host from your local machine</span>

scp -i ~/.ssh/id_rsa docker/init.sql ec2-user@<span class="hljs-variable">$BASTION_IP</span>:/tmp/
scp -i ~/.ssh/id_rsa migrations/*.sql ec2-user@<span class="hljs-variable">$BASTION_IP</span>:/tmp/

<span class="hljs-comment"># Then SSH into it and run migrations</span>
ssh -i ~/.ssh ec2-user@<span class="hljs-variable">$BASTION_IP</span>

<span class="hljs-comment"># Inside the bastion:</span>
psql -h <span class="hljs-variable">$DB_ENDPOINT</span> -U dbadmin -d ecommerce -f /tmp/init.sql

<span class="hljs-comment"># Verify</span>
psql -h <span class="hljs-variable">$DB_HOST</span> -U <span class="hljs-variable">$DB_USER</span> -d <span class="hljs-variable">$DB_NAME</span> -c <span class="hljs-string">"\d customers"</span>

<span class="hljs-comment"># Exit the container</span>
<span class="hljs-built_in">exit</span>
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768089062401/8f23655e-b50b-4b24-af98-b195e29da9c7.png" alt="Figure 6: Database schema - the customers table with the original columns" class="image--center mx-auto" width="1298" height="402" loading="lazy"></p>
<h3 id="heading-step-3-verify-blue-environment">Step 3: Verify Blue Environment</h3>
<p>We’ll want to test that everything works before we start the migration. This is your baseline: you want to confirm that the current system is healthy before introducing changes.</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Check health</span>
curl <span class="hljs-variable">$ALB_URL</span>/health | jq

<span class="hljs-comment"># Expected response:</span>
<span class="hljs-comment"># {</span>
<span class="hljs-comment">#   "status": "healthy",</span>
<span class="hljs-comment">#   "version": "blue",</span>
<span class="hljs-comment">#   "environment": "production",</span>
<span class="hljs-comment">#   "database": "connected",</span>
<span class="hljs-comment">#   "schema": "compatible"</span>
<span class="hljs-comment"># }</span>

<span class="hljs-comment"># Create a customer with the old schema (single address field)</span>
curl -X POST <span class="hljs-variable">$ALB_URL</span>/api/customers \
    -H <span class="hljs-string">"Content-Type: application/json"</span> \
    -d <span class="hljs-string">'{
      "name": "John Doe",
      "email": "john@example.com",
      "address": "123 Main St, New York, NY, 10001"
    }'</span> | jq

<span class="hljs-comment"># List customers</span>
curl <span class="hljs-variable">$ALB_URL</span>/api/customers | jq
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768138569485/b7455a6e-b101-4cdb-83b8-40e0dbafb0b0.png" alt="Figure 7: Blue Environment Verification" class="image--center mx-auto" width="1068" height="434" loading="lazy"></p>
<h3 id="heading-step-4-expand-phase-add-new-columns">Step 4: Expand Phase – Add New Columns</h3>
<p>This is the first phase of expand-contract. We're adding the new columns WITHOUT removing the old one, creating a database schema that supports both blue and green simultaneously.</p>
<p>Run the expand migration (<a target="_blank" href="https://github.com/Caesarsage/bluegreen-deployment-ecs/blob/main/migrations/001_expand_address.sql"><code>migrations/001_expand_address.sql</code>)</a>:</p>
<pre><code class="lang-sql"><span class="hljs-comment">-- Migration: 001_expand_address_fields.sql</span>
<span class="hljs-keyword">BEGIN</span>;

<span class="hljs-keyword">ALTER</span> <span class="hljs-keyword">TABLE</span> customers 
  <span class="hljs-keyword">ADD</span> <span class="hljs-keyword">COLUMN</span> street_address <span class="hljs-built_in">VARCHAR</span>(<span class="hljs-number">255</span>),
  <span class="hljs-keyword">ADD</span> <span class="hljs-keyword">COLUMN</span> city <span class="hljs-built_in">VARCHAR</span>(<span class="hljs-number">100</span>),
  <span class="hljs-keyword">ADD</span> <span class="hljs-keyword">COLUMN</span> state <span class="hljs-built_in">VARCHAR</span>(<span class="hljs-number">2</span>),
  <span class="hljs-keyword">ADD</span> <span class="hljs-keyword">COLUMN</span> zip_code <span class="hljs-built_in">VARCHAR</span>(<span class="hljs-number">10</span>);

<span class="hljs-comment">-- Populate new columns from existing data</span>
<span class="hljs-comment">-- This uses a simple parsing strategy; yours might be more sophisticated</span>

<span class="hljs-keyword">UPDATE</span> customers 
<span class="hljs-keyword">SET</span> 
  street_address = SPLIT_PART(address, <span class="hljs-string">','</span>, <span class="hljs-number">1</span>),
  city = <span class="hljs-keyword">TRIM</span>(SPLIT_PART(address, <span class="hljs-string">','</span>, <span class="hljs-number">2</span>)),
  state = <span class="hljs-keyword">TRIM</span>(SPLIT_PART(address, <span class="hljs-string">','</span>, <span class="hljs-number">3</span>)),
  zip_code = <span class="hljs-keyword">TRIM</span>(SPLIT_PART(address, <span class="hljs-string">','</span>, <span class="hljs-number">4</span>))
<span class="hljs-keyword">WHERE</span> address <span class="hljs-keyword">IS</span> <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>;

<span class="hljs-keyword">COMMIT</span>;
</code></pre>
<p><strong>Critical observation:</strong> We're NOT dropping the <code>address</code> column. It's still there. Blue continues reading and writing to it, completely unaware that new columns exist. This is what makes the migration safe – nothing breaks.</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Then SSH into it and run migrations</span>
ssh -i ~/.ssh ec2-user@<span class="hljs-variable">$BASTION_IP</span>

<span class="hljs-comment"># Inside the bastion:</span>
<span class="hljs-built_in">export</span> DB_ENDPOINT = <span class="hljs-string">""</span> <span class="hljs-comment"># from terraform output</span>

psql -h <span class="hljs-variable">$DB_ENDPOINT</span> -U dbadmin -d ecommerce -f /tmp/001_expand_address.sql

<span class="hljs-comment"># Verify new columns exist</span>
psql -h <span class="hljs-variable">$DB_ENDPOINT</span> -U dbadmin -d ecommerce -c <span class="hljs-string">"\d customers"</span>

<span class="hljs-built_in">exit</span>
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768089194050/e053dee3-382b-4ccd-a0e0-8c17003e9832.png" alt="Figure 8: Database schema evolution - the customers table during expand phase with both old and new columns" class="image--center mx-auto" width="1638" height="694" loading="lazy"></p>
<p><strong>Verification:</strong> The <code>\d customers</code> command shows the table structure. You should see BOTH the old <code>address</code> column AND the new <code>street_address</code>, <code>city</code>, <code>state</code>, <code>zip_code</code> columns. This confirms the expand phase worked.</p>
<p>The database now supports both old (blue) and new (green) schemas. Blue is still running and working perfectly, and nothing has changed from its perspective.</p>
<h3 id="heading-step-5-build-and-deploy-green-environment">Step 5: Build and Deploy Green Environment</h3>
<p>Now we’ll build version 2 of our application that knows how to work with the new structured address fields, while maintaining backwards compatibility with the old schema.</p>
<p>Start by building version 2 with structured address support:</p>
<pre><code class="lang-bash"><span class="hljs-built_in">cd</span> ..  <span class="hljs-comment"># Back to project root</span>

<span class="hljs-comment"># Build new version</span>
<span class="hljs-built_in">export</span> IMAGE_TAG=v2.0.0

docker build --platform linux/amd64 -t <span class="hljs-variable">$ECR_REPOSITORY</span>:<span class="hljs-variable">$IMAGE_TAG</span> -f docker/Dockerfile .

docker tag <span class="hljs-variable">$ECR_REPOSITORY</span>:<span class="hljs-variable">$IMAGE_TAG</span> \
    <span class="hljs-variable">$AWS_ACCOUNT_ID</span>.dkr.ecr.<span class="hljs-variable">$AWS_REGION</span>.amazonaws.com/<span class="hljs-variable">$ECR_REPOSITORY</span>:<span class="hljs-variable">$IMAGE_TAG</span>

docker push <span class="hljs-variable">$AWS_ACCOUNT_ID</span>.dkr.ecr.<span class="hljs-variable">$AWS_REGION</span>.amazonaws.com/<span class="hljs-variable">$ECR_REPOSITORY</span>:<span class="hljs-variable">$IMAGE_TAG</span>
</code></pre>
<p>What’s different is that the v2 <a target="_blank" href="https://github.com/Caesarsage/bluegreen-deployment-ecs/blob/main/app/models.py">application code</a> now has logic that:</p>
<ul>
<li><p><strong>Reads</strong> from the new structured columns (<code>street_address</code>, <code>city</code>, and so on)</p>
</li>
<li><p><strong>Writes</strong> to BOTH new columns AND the old <code>address</code> column</p>
</li>
<li><p>Accepts API requests with structured address format</p>
</li>
</ul>
<p><strong>Why write to both:</strong> This is crucial. Even though green prefers the new format, it maintains the old format, too. If you need to rollback to blue, all the data blue needs is there and up-to-date. Without this, rollback would be impossible: blue would see empty or stale <code>address</code> fields.</p>
<p>Now create and register green task definition:</p>
<pre><code class="lang-bash"><span class="hljs-built_in">cd</span> terraform

<span class="hljs-comment"># Get necessary ARNs</span>
EXECUTION_ROLE_ARN=$(terraform output -raw ecs_task_execution_role_arn)
TASK_ROLE_ARN=$(terraform output -raw ecs_task_role_arn)
DB_SECRET_ARN=$(terraform output -raw db_secret_arn)

<span class="hljs-comment"># Create task definition</span>
cat &gt; task-def-green.json &lt;&lt;EOF
{
  <span class="hljs-string">"family"</span>: <span class="hljs-string">"ecommerce-bluegreen"</span>,
  <span class="hljs-string">"networkMode"</span>: <span class="hljs-string">"awsvpc"</span>,
  <span class="hljs-string">"requiresCompatibilities"</span>: [<span class="hljs-string">"FARGATE"</span>],
  <span class="hljs-string">"cpu"</span>: <span class="hljs-string">"256"</span>,
  <span class="hljs-string">"memory"</span>: <span class="hljs-string">"512"</span>,
  <span class="hljs-string">"executionRoleArn"</span>: <span class="hljs-string">"<span class="hljs-variable">${EXECUTION_ROLE_ARN}</span>"</span>,
  <span class="hljs-string">"taskRoleArn"</span>: <span class="hljs-string">"<span class="hljs-variable">${TASK_ROLE_ARN}</span>"</span>,
  <span class="hljs-string">"containerDefinitions"</span>: [{
    <span class="hljs-string">"name"</span>: <span class="hljs-string">"app"</span>,
    <span class="hljs-string">"image"</span>: <span class="hljs-string">"<span class="hljs-variable">${AWS_ACCOUNT_ID}</span>.dkr.ecr.<span class="hljs-variable">${AWS_REGION}</span>.amazonaws.com/<span class="hljs-variable">${ECR_REPOSITORY}</span>:<span class="hljs-variable">${IMAGE_TAG}</span>"</span>,
    <span class="hljs-string">"essential"</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-string">"portMappings"</span>: [{
      <span class="hljs-string">"containerPort"</span>: 8080,
      <span class="hljs-string">"protocol"</span>: <span class="hljs-string">"tcp"</span>
    }],
    <span class="hljs-string">"environment"</span>: [
      {<span class="hljs-string">"name"</span>: <span class="hljs-string">"APP_VERSION"</span>, <span class="hljs-string">"value"</span>: <span class="hljs-string">"green"</span>},
      {<span class="hljs-string">"name"</span>: <span class="hljs-string">"ENVIRONMENT"</span>, <span class="hljs-string">"value"</span>: <span class="hljs-string">"production"</span>},
      {<span class="hljs-string">"name"</span>: <span class="hljs-string">"AWS_REGION"</span>, <span class="hljs-string">"value"</span>: <span class="hljs-string">"<span class="hljs-variable">${AWS_REGION}</span>"</span>},
      {<span class="hljs-string">"name"</span>: <span class="hljs-string">"DB_HOST"</span>, <span class="hljs-string">"value"</span>: <span class="hljs-string">"<span class="hljs-variable">${DB_ENDPOINT}</span>"</span>},
      {<span class="hljs-string">"name"</span>: <span class="hljs-string">"DB_PORT"</span>, <span class="hljs-string">"value"</span>: <span class="hljs-string">"5432"</span>},
      {<span class="hljs-string">"name"</span>: <span class="hljs-string">"DB_NAME"</span>, <span class="hljs-string">"value"</span>: <span class="hljs-string">"ecommerce"</span>}
    ],
    <span class="hljs-string">"secrets"</span>: [
      {
        <span class="hljs-string">"name"</span>: <span class="hljs-string">"DB_USER"</span>,
        <span class="hljs-string">"valueFrom"</span>: <span class="hljs-string">"<span class="hljs-variable">${DB_SECRET_ARN}</span>:username::"</span>
      },
      {
        <span class="hljs-string">"name"</span>: <span class="hljs-string">"DB_PASSWORD"</span>,
        <span class="hljs-string">"valueFrom"</span>: <span class="hljs-string">"<span class="hljs-variable">${DB_SECRET_ARN}</span>:password::"</span>
      }
    ],
    <span class="hljs-string">"logConfiguration"</span>: {
      <span class="hljs-string">"logDriver"</span>: <span class="hljs-string">"awslogs"</span>,
      <span class="hljs-string">"options"</span>: {
        <span class="hljs-string">"awslogs-group"</span>: <span class="hljs-string">"/ecs/ecommerce-bluegreen"</span>,
        <span class="hljs-string">"awslogs-region"</span>: <span class="hljs-string">"<span class="hljs-variable">${AWS_REGION}</span>"</span>,
        <span class="hljs-string">"awslogs-stream-prefix"</span>: <span class="hljs-string">"ecs"</span>
      }
    },
    <span class="hljs-string">"healthCheck"</span>: {
      <span class="hljs-string">"command"</span>: [<span class="hljs-string">"CMD-SHELL"</span>, <span class="hljs-string">"curl -f http://localhost:8080/health || exit 1"</span>],
      <span class="hljs-string">"interval"</span>: 30,
      <span class="hljs-string">"timeout"</span>: 5,
      <span class="hljs-string">"retries"</span>: 3,
      <span class="hljs-string">"startPeriod"</span>: 60
    }
  }]
}
EOF

<span class="hljs-comment"># Register the task definition</span>
aws ecs register-task-definition --cli-input-json file://task-def-green.json
</code></pre>
<p>This JSON tells ECS everything about how to run your container:</p>
<ul>
<li><p>Which Docker image to use (the v2.0.0 we just built)</p>
</li>
<li><p>How much CPU/memory to allocate (256 CPU units = 0.25 vCPU)</p>
</li>
<li><p>Environment variables (notice <code>APP_VERSION</code> is set to "green")</p>
</li>
<li><p>Secrets (database credentials pulled from AWS Secrets Manager)</p>
</li>
<li><p>Health check configuration (curl the /health endpoint every 30 seconds)</p>
</li>
<li><p>Logging configuration (send logs to CloudWatch)</p>
</li>
</ul>
<p><strong>Key detail:</strong> The <code>APP_VERSION</code> environment variable is how the application knows whether to behave as blue or green. Same codebase, different behavior based on configuration.</p>
<h3 id="heading-step-6-execute-blue-green-deployment">Step 6: Execute Blue-Green Deployment</h3>
<p>Alright, now it’s time to create AppSpec and trigger the deployment:</p>
<pre><code class="lang-bash">TASK_DEF_ARN=$(aws ecs describe-task-definition \
  --task-definition ecommerce-bluegreen \
  --query <span class="hljs-string">'taskDefinition.taskDefinitionArn'</span> \
  --output text)

cat &gt; appspec.json &lt;&lt;EOF
{
  <span class="hljs-string">"version"</span>: 0.0,
  <span class="hljs-string">"Resources"</span>: [{
    <span class="hljs-string">"TargetService"</span>: {
      <span class="hljs-string">"Type"</span>: <span class="hljs-string">"AWS::ECS::Service"</span>,
      <span class="hljs-string">"Properties"</span>: {
        <span class="hljs-string">"TaskDefinition"</span>: <span class="hljs-string">"<span class="hljs-variable">${TASK_DEF_ARN}</span>"</span>,
        <span class="hljs-string">"LoadBalancerInfo"</span>: {
          <span class="hljs-string">"ContainerName"</span>: <span class="hljs-string">"app"</span>,
          <span class="hljs-string">"ContainerPort"</span>: 8080
        }
      }
    }
  }]
}
EOF

<span class="hljs-comment"># Deploy</span>
APPSPEC=$(cat appspec.json | jq -c .)
aws deploy create-deployment \
  --application-name ecommerce-bluegreen \
  --deployment-group-name ecommerce-bluegreen-deployment-group \
  --deployment-config-name CodeDeployDefault.ECSLinear10PercentEvery3Minutes \
  --description <span class="hljs-string">"Blue-green deployment to structured address schema"</span> \
  --cli-input-json <span class="hljs-string">"{
    \"revision\": {
      \"revisionType\": \"AppSpecContent\",
      \"appSpecContent\": {
        \"content\": <span class="hljs-subst">$(echo \"$APPSPEC\" | jq -Rs .)</span>
      }
    }
  }"</span>

DEPLOYMENT_ID=$(aws deploy list-deployments \
    --application-name ecommerce-bluegreen \
    --deployment-group-name ecommerce-bluegreen-deployment-group \
    --query <span class="hljs-string">'deployments[0]'</span> --output text)
</code></pre>
<p>Monitor the deployment:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Watch status</span>
watch -n 10 <span class="hljs-string">"aws deploy get-deployment --deployment-id <span class="hljs-variable">$DEPLOYMENT_ID</span> \
    --query 'deploymentInfo.status' --output text"</span>

<span class="hljs-comment"># Monitor traffic distribution</span>
<span class="hljs-keyword">while</span> <span class="hljs-literal">true</span>; <span class="hljs-keyword">do</span>
    <span class="hljs-built_in">echo</span> <span class="hljs-string">"Production: <span class="hljs-subst">$(curl -s $ALB_URL/health | jq -r '.version')</span>"</span>
    <span class="hljs-built_in">echo</span> <span class="hljs-string">"Test: <span class="hljs-subst">$(curl -s $TEST_URL/health | jq -r '.version')</span>"</span>
    sleep 30
<span class="hljs-keyword">done</span>
</code></pre>
<p>The deployment shifts 10% of traffic every 3 minutes, completing in 30 minutes.</p>
<h3 id="heading-step-7-validate-green-environment">Step 7: Validate Green Environment</h3>
<p>After the deployment begins, you need to validate that the green environment is functioning correctly with the new structured address format before allowing production traffic to reach it.</p>
<p>The CodeBuild dashboard below shows the Traffic migration and Deployment status:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768093087711/fc1b869c-7fae-421e-8d98-45769300cb0a.png" alt="Monitoring in CodeDeploy" class="image--center mx-auto" width="2282" height="1460" loading="lazy"></p>
<p>We can also test through the test listener (port 8080), which provides isolated access to green tasks:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Test new structured address API</span>
curl -X POST <span class="hljs-variable">$TEST_URL</span>/api/customers \
    -H <span class="hljs-string">"Content-Type: application/json"</span> \
    -d <span class="hljs-string">'{
      "name": "Jane Smith",
      "email": "jane@example.com",
      "address": {
        "street": "456 Oak Ave",
        "city": "Los Angeles",
        "state": "CA",
        "zip": "90001"
      }
    }'</span> | jq

curl <span class="hljs-variable">$ALB_URL</span>/api/customers | jq
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768140730325/57c6a047-994f-4b5e-8e19-4d6fb25ad44e.png" alt="Validate Green environment response" class="image--center mx-auto" width="1422" height="672" loading="lazy"></p>
<p>What you're validating:</p>
<ul>
<li><p>The green environment accepts the new structured address format</p>
</li>
<li><p>Data is correctly written to both new columns (street_address, city, state, zip_code) and the old address column for backwards compatibility</p>
</li>
<li><p>The API response matches expectations for the new schema</p>
</li>
<li><p>Existing data from blue environment is still accessible and readable</p>
</li>
</ul>
<p>If any of these tests fail, you can stop the deployment before production traffic reaches green, preventing customer impact.</p>
<h3 id="heading-step-8-post-deployment-validation">Step 8: Post-Deployment Validation</h3>
<p>Once CodeDeploy completes the traffic shift, all production requests route to green. This is your opportunity to verify that the deployment was successful and that the new version is handling real production traffic correctly.</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Verify all production traffic goes to green</span>
<span class="hljs-comment"># Running this multiple times confirms consistent routing</span>
<span class="hljs-keyword">for</span> i <span class="hljs-keyword">in</span> {1..10}; <span class="hljs-keyword">do</span>
    curl -s <span class="hljs-variable">$ALB_URL</span>/health | jq -r <span class="hljs-string">'.version'</span>
<span class="hljs-keyword">done</span>
<span class="hljs-comment"># Expected output: "green" for all 10 requests</span>

<span class="hljs-comment"># Test complete CRUD operations with the new API</span>
<span class="hljs-comment"># Create a customer with structured address</span>
CUSTOMER_ID=$(curl -s -X POST <span class="hljs-variable">$ALB_URL</span>/api/customers \
    -H <span class="hljs-string">"Content-Type: application/json"</span> \
    -d <span class="hljs-string">'{"name": "Test User", "email": "test@example.com",
         "address": {"street": "789 Test St", "city": "Test City", 
         "state": "TX", "zip": "75001"}}'</span> | jq -r <span class="hljs-string">'.id'</span>)

<span class="hljs-comment"># Read the customer back to verify data persistence</span>
curl <span class="hljs-variable">$ALB_URL</span>/api/customers/<span class="hljs-variable">$CUSTOMER_ID</span> | jq

<span class="hljs-comment"># Update the customer to test modification</span>
curl -X PUT <span class="hljs-variable">$ALB_URL</span>/api/customers/<span class="hljs-variable">$CUSTOMER_ID</span> \
    -H <span class="hljs-string">"Content-Type: application/json"</span> \
    -d <span class="hljs-string">'{"address": {"street": "999 Updated Ave", "city": "Test City", 
         "state": "TX", "zip": "75001"}}'</span> | jq

<span class="hljs-comment"># Delete the test customer for cleanup</span>
curl -X DELETE <span class="hljs-variable">$ALB_URL</span>/api/customers/<span class="hljs-variable">$CUSTOMER_ID</span>
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768140850962/a31273e9-cbc1-4d09-9f6d-7248b402f712.png" alt="Verify all production traffic goes to green" class="image--center mx-auto" width="846" height="270" loading="lazy"></p>
<p>What you're validating:</p>
<ul>
<li><p>Traffic routing is 100% to green with no requests reaching blue</p>
</li>
<li><p>Create operations work with the new structured address format</p>
</li>
<li><p>Read operations return correct data with proper address structure</p>
</li>
<li><p>Update operations successfully modify existing records</p>
</li>
<li><p>Delete operations work without errors</p>
</li>
<li><p>The application correctly writes to both new columns and old address column (enabling potential rollback)</p>
</li>
</ul>
<p>Check your CloudWatch logs and metrics during this validation period for any unexpected errors, increased latency, or database connection issues.</p>
<h3 id="heading-step-9-contract-phase-after-24-72-hours">Step 9: Contract Phase (After 24-72 Hours)</h3>
<p>This is the final phase of expand-contract. We're removing the old <code>address</code> column now that we're confident green is stable. This is the point of no return.</p>
<p><strong>CRITICAL</strong>: Only proceed after green has been stable for your confidence period!</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Backup database first</span>
aws rds create-db-snapshot \
    --db-instance-identifier ecommerce-bluegreen-db \
    --db-snapshot-identifier pre-contract-$(date +%Y%m%d-%H%M%S)

<span class="hljs-comment"># Wait for snapshot</span>
aws rds <span class="hljs-built_in">wait</span> db-snapshot-completed \
    --db-snapshot-identifier pre-contract-$(date +%Y%m%d-%H%M%S)

<span class="hljs-comment"># Run contract migration</span>
psql -h <span class="hljs-variable">$DB_ENDPOINT</span> -U dbadmin -d ecommerce -f /tmp/002_contract_address.sql

<span class="hljs-comment"># Verify old column is gone</span>
psql -h <span class="hljs-variable">$DB_ENDPOINT</span> -U dbadmin -d ecommerce -c <span class="hljs-string">"\d customers"</span>
</code></pre>
<p>The contract migration (<a target="_blank" href="https://github.com/Caesarsage/bluegreen-deployment-ecs/blob/main/migrations/002_contract_address.sql"><code>migrations/002_contract_address.sql</code></a>) removes the old <code>address</code> column.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768140955991/d6f6f287-09e5-4693-a4e9-77c1d9080466.png" alt="d6f6f287-09e5-4693-a4e9-77c1d9080466" class="image--center mx-auto" width="1506" height="444" loading="lazy"></p>
<p><strong>Why wait 24-72 hours:</strong> You want to be absolutely certain green is stable before making irreversible changes. During this waiting period:</p>
<ul>
<li><p>All your monitoring should show green performing normally</p>
</li>
<li><p>You've seen the system handle multiple daily traffic patterns (morning peak, evening peak, overnight)</p>
</li>
<li><p>Weekly batch jobs have run successfully</p>
</li>
<li><p>You've verified third-party integrations work</p>
</li>
<li><p>No unusual errors or performance degradation</p>
</li>
</ul>
<p>It’s important to snapshot first because once you drop that column, there's no undo button. The snapshot is your safety net. If you discover a critical issue after contracting, you can restore this snapshot and get back to a state where rollback is possible. Without it, you're gambling.</p>
<p><strong>What the contract migration does:</strong></p>
<pre><code class="lang-sql"><span class="hljs-comment">-- migrations/002_contract_address.sql</span>
<span class="hljs-keyword">BEGIN</span>;
<span class="hljs-keyword">ALTER</span> <span class="hljs-keyword">TABLE</span> customers <span class="hljs-keyword">DROP</span> <span class="hljs-keyword">COLUMN</span> address;
<span class="hljs-keyword">COMMIT</span>;
</code></pre>
<p>It's simple but permanent. The old <code>address</code> column is gone. The Blue environment will no longer work with this database, as it expects that column to exist. This is fine because blue has been decommissioned (no traffic, tasks terminated).</p>
<p><strong>What to update:</strong> You should also deploy version 3 of your application that removes the dual-write logic. Version 2 (green) is still writing to both the new columns and the old <code>address</code> column. Version 3 can stop wasting cycles writing to a column that no longer exists.</p>
<p>The contract migration (<a target="_blank" href="https://github.com/Caesarsage/bluegreen-deployment-ecs/blob/main/migrations/002_contract_address.sql"><code>migrations/002_contract_address.sql</code></a>) removes the old <code>address</code> column. Your migration is now complete!</p>
<h2 id="heading-rollback-strategies">Rollback Strategies</h2>
<h3 id="heading-during-deployment-safe-window">During Deployment (Safe Window)</h3>
<p>Use this strategy when you detect issues <strong>during the traffic shift</strong>, before all traffic has moved to green. CodeDeploy is still managing the deployment, which means it can automatically revert traffic distribution to the previous state.</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Immediate rollback</span>
aws deploy stop-deployment \
    --deployment-id <span class="hljs-variable">$DEPLOYMENT_ID</span> \
    --auto-rollback-enabled
</code></pre>
<p>You should use this strategy when you notice increased error rates, degraded performance, or functional issues during the canary or linear traffic shift. CodeDeploy automatically shifts all traffic back to blue, and green tasks are terminated. This is the safest and fastest rollback option.</p>
<p>This works because the database still contains the old <code>address</code> column (expand phase), so blue can function normally. No data has been lost or made incompatible.</p>
<h3 id="heading-after-deployment-before-contract">After Deployment (Before Contract)</h3>
<p>Use this when the deployment completed successfully, but you discover issues hours or days later during the monitoring period, before you've run the contract migration. Both blue and green environments still exist, and the database supports both schemas.</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Manual listener update</span>
aws elbv2 modify-listener \
    --listener-arn $(terraform output -raw alb_listener_arn) \
    --default-actions Type=forward,TargetGroupArn=$(terraform output -raw blue_target_group_arn)
</code></pre>
<p>Or use the provided script:</p>
<pre><code class="lang-bash"><span class="hljs-built_in">cd</span> scripts
./rollback.sh
</code></pre>
<p>Use this when you discover bugs in green that weren't caught during initial testing, business metrics show unexpected changes (conversion rates drop, customer complaints increase), or third-party integration issues emerge.</p>
<p>This works because the database still has both old and new schema elements. Blue tasks still exist and can serve traffic immediately. Because green was writing to both old and new columns, blue sees all the latest data.</p>
<p>With this, the traffic immediately shifts from green back to blue. Green continues running for observability, but serves no traffic. You can debug green in place without customer impact.</p>
<h3 id="heading-after-contract-phase">After Contract Phase</h3>
<p>Use this as a <strong>last resort</strong> when you've already removed the old address column, and blue can no longer function with the current database schema. This is significantly more complex and time-consuming than the previous two strategies.</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Restore from snapshot</span>
aws rds restore-db-instance-from-db-snapshot \
    --db-instance-identifier ecommerce-bluegreen-db-restored \
    --db-snapshot-identifier pre-contract-YYYYMMDD-HHMMSS
</code></pre>
<p>Only use this strategy when you discover a critical, production-breaking issue after the contract phase, and you have no other option but to return to the previous version.</p>
<p><strong>Why it's painful</strong>:</p>
<ul>
<li><p>Database restore takes 10-30 minutes depending on size</p>
</li>
<li><p>You lose all data written after the snapshot was taken</p>
</li>
<li><p>Requires updating connection strings to point to the restored instance</p>
</li>
<li><p>Need to re-deploy blue environment</p>
</li>
<li><p>Must communicate downtime to users</p>
</li>
</ul>
<p>This is why you wait 24-72 hours before contracting, and take a snapshot immediately before the contract migration. The lengthy waiting period allows you to catch most issues while the safer rollback strategies are still available.</p>
<h2 id="heading-monitoring-during-deployments">Monitoring During Deployments</h2>
<h3 id="heading-essential-metrics">Essential Metrics</h3>
<p>During a blue-green deployment, you need to monitor both environments simultaneously to detect issues early and make informed decisions about proceeding or rolling back.For each target group (blue and green), track these CloudWatch metrics:</p>
<h4 id="heading-1-targetresponsetime">1. TargetResponseTime</h4>
<p>Measures latency from when the load balancer sends a request to when it receives a response. You're looking for sudden spikes or gradual degradation. Green should have similar response times to blue (within 10-20%). If green's latency is significantly higher, you may have performance regressions, inefficient queries with the new schema, or resource constraints.</p>
<h4 id="heading-2-requestcount">2. RequestCount</h4>
<p>Shows traffic volume hitting each target group. During the deployment, you should see blue's count decreasing while green's increases proportionally. If the numbers don't add up (total requests drop significantly), users might be experiencing errors and not retrying. If green receives traffic but shows zero requests, health checks might be failing.</p>
<h4 id="heading-3-httpcodetarget5xxcount">3. HTTPCode_Target_5XX_Count</h4>
<p>Server errors indicate application problems. Even a single 5XX error during deployment warrants investigation. Green should have zero 5XX errors during the initial traffic shift. Any errors could indicate incompatibility issues with the new schema, missing environment variables, or database connection problems.</p>
<h4 id="heading-4-databaseconnections-from-rds-metrics">4. DatabaseConnections (from RDS metrics):</h4>
<p>Shows active database connections from both environments. Watch for connection pool exhaustion, which manifests as a sudden spike or plateau at your max connections limit. If green uses more connections than blue did, you might have connection leaks or inefficient connection handling in the new code.</p>
<h4 id="heading-5-cpuutilization">5. CPUUtilization</h4>
<p>Monitor both ECS task CPU and RDS CPU. Green tasks should use similar CPU to blue tasks for the same request volume. Higher CPU might indicate less efficient code or more complex queries. RDS CPU spikes during deployment often indicate poorly optimized new queries or missing indexes for the new schema.</p>
<p><strong>What to expect</strong>:</p>
<ul>
<li><p>First 5-10 minutes: Green receives 10% traffic, metrics should closely match blue's baseline</p>
</li>
<li><p>15-20 minutes: Green at 30-50% traffic, both environments should show stable metrics</p>
</li>
<li><p>25-30 minutes: Green at 100% traffic, metrics should stabilize at historical levels</p>
</li>
<li><p>Any divergence from these patterns warrants stopping the deployment and investigating</p>
</li>
</ul>
<p><strong>Custom application metrics</strong>: Beyond infrastructure metrics, monitor business-critical metrics like checkout completion rates, API success rates, and user sign-up flows. Sometimes technical metrics look fine but user-facing functionality is broken.</p>
<h2 id="heading-best-practices">Best Practices</h2>
<h3 id="heading-test-migrations-in-staging">Test Migrations in Staging</h3>
<p>Always run your database migrations against a staging environment that mirrors production scale and complexity before touching production. Copy a recent production snapshot to staging and execute your expand migration there first.</p>
<p><strong>Why this matters</strong>: Migrations that work fine on small datasets can timeout or lock tables on production-scale data. You might discover that adding an index to a 50-million-row table takes 2 hours, or that your column population query needs optimization.</p>
<p><strong>What to test</strong>:</p>
<ul>
<li><p>Migration execution time (should complete in seconds/minutes, not hours)</p>
</li>
<li><p>Table locks and their impact (can reads/writes continue during migration?)</p>
</li>
<li><p>Query performance with new schema (are your indexes still effective?)</p>
</li>
<li><p>Rollback procedures (can you undo the migration if needed?)</p>
</li>
</ul>
<h3 id="heading-use-migration-tools">Use Migration Tools</h3>
<p>Don't write raw SQL migrations manually. Use Flyway, Liquibase, Alembic (for Python), or your framework's built-in migration tools (Rails migrations, Django migrations, Entity Framework migrations).</p>
<p><strong>Why this matters</strong>: Migration tools provide version tracking, rollback capabilities, checksums to prevent tampering, and a standardized way to manage schema changes across environments.</p>
<h3 id="heading-configure-health-checks-properly">Configure Health Checks Properly</h3>
<p>Your health check endpoint should verify that the application can actually function, not just that the process is running. A comprehensive health check validates database connectivity, schema compatibility, and dependent service availability.</p>
<pre><code class="lang-python"><span class="hljs-meta">@app.route('/health')</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">health_check</span>():</span>
    checks = {
        <span class="hljs-string">'database'</span>: check_database(),
        <span class="hljs-string">'schema'</span>: check_schema_compatibility(),
        <span class="hljs-string">'cache'</span>: check_cache_connection()
    }

    <span class="hljs-keyword">if</span> all(checks.values()):
        <span class="hljs-keyword">return</span> jsonify(checks), <span class="hljs-number">200</span>
    <span class="hljs-keyword">else</span>:
        <span class="hljs-keyword">return</span> jsonify(checks), <span class="hljs-number">503</span>

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">check_schema_compatibility</span>():</span>
    <span class="hljs-string">"""Verify expected schema elements exist"""</span>
    <span class="hljs-keyword">try</span>:
        result = db.query(<span class="hljs-string">"""
            SELECT column_name 
            FROM information_schema.columns 
            WHERE table_name = 'customers'
            AND column_name IN ('street_address', 'city', 'state', 'zip_code')
        """</span>)
        <span class="hljs-keyword">return</span> len(result) == <span class="hljs-number">4</span>
    <span class="hljs-keyword">except</span>:
        <span class="hljs-keyword">return</span> <span class="hljs-literal">False</span>
</code></pre>
<p>For ALB health checks specifically, make sure you configure appropriate thresholds in your target group settings. A healthy threshold of 2 means the target must pass 2 consecutive health checks before receiving traffic. An unhealthy threshold of 3 means it must fail 3 consecutive checks before being removed. Set your interval to 30 seconds and timeout to 5 seconds to balance responsiveness with stability.</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Terraform configuration for ALB health checks</span>
resource <span class="hljs-string">"aws_lb_target_group"</span> <span class="hljs-string">"green"</span> {
  health_check {
    enabled             = <span class="hljs-literal">true</span>
    healthy_threshold   = 2
    unhealthy_threshold = 3
    timeout             = 5
    interval            = 30
    path                = <span class="hljs-string">"/health"</span>
    matcher             = <span class="hljs-string">"200"</span>
  }
}
</code></pre>
<p>This configuration ensures that ECS tasks aren't marked healthy prematurely (preventing traffic to broken tasks) while also not being overly sensitive to transient issues (preventing unnecessary task replacements).</p>
<h3 id="heading-plan-the-contract-phase">Plan the Contract Phase</h3>
<p>The contract phase is irreversible, so treat it with appropriate caution. Wait a minimum of 24-72 hours after green deployment before removing old schema elements. This waiting period isn't arbitrary: it ensures you've observed the system under various conditions.</p>
<p><strong>What to verify before contracting</strong>:</p>
<ul>
<li><p>Green has handled multiple daily traffic patterns (morning rush, evening peak, overnight batch jobs)</p>
</li>
<li><p>All scheduled jobs and cron tasks have run successfully with the new schema</p>
</li>
<li><p>Weekly reports or analytics pipelines have completed</p>
</li>
<li><p>Third-party integrations (payment processors, shipping APIs, analytics tools) are working</p>
</li>
<li><p>No unusual error patterns in logs</p>
</li>
<li><p>Business metrics (conversions, sign-ups, purchases) remain stable</p>
</li>
<li><p>Customer support hasn't reported related issues</p>
</li>
</ul>
<p>The pre-contract checklist:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># 1. Create a final snapshot</span>
aws rds create-db-snapshot \
    --db-instance-identifier ecommerce-bluegreen-db \
    --db-snapshot-identifier pre-contract-$(date +%Y%m%d-%H%M%S)

<span class="hljs-comment"># 2. Document current state</span>
<span class="hljs-built_in">echo</span> <span class="hljs-string">"Green tasks: <span class="hljs-subst">$(aws ecs describe-services --cluster ecommerce --services ecommerce-green | jq '.services[0].runningCount')</span>"</span>
<span class="hljs-built_in">echo</span> <span class="hljs-string">"Error rate: <span class="hljs-subst">$(aws cloudwatch get-metric-statistics --namespace AWS/ApplicationELB --metric-name HTTPCode_Target_5XX_Count --start-time $(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%S)</span> --end-time <span class="hljs-subst">$(date -u +%Y-%m-%dT%H:%M:%S)</span> --period 3600 --statistics Sum)"</span>

<span class="hljs-comment"># 3. Notify team</span>
<span class="hljs-built_in">echo</span> <span class="hljs-string">"Running contract migration at <span class="hljs-subst">$(date)</span>"</span>

<span class="hljs-comment"># 4. Run migration</span>
psql -h <span class="hljs-variable">$DB_ENDPOINT</span> -U dbadmin -d ecommerce -f migrations/002_contract_address.sql

<span class="hljs-comment"># 5. Verify</span>
psql -h <span class="hljs-variable">$DB_ENDPOINT</span> -U dbadmin -d ecommerce -c <span class="hljs-string">"\d customers"</span>
</code></pre>
<h3 id="heading-version-your-apis">Version Your APIs</h3>
<p>When changing data formats, maintain backward compatibility by supporting both old and new API versions simultaneously. This allows API consumers (mobile apps, third-party integrations, other services) to migrate at their own pace without coordinating releases.</p>
<pre><code class="lang-python"><span class="hljs-comment"># Support both API versions during transition</span>
<span class="hljs-meta">@app.route('/api/v1/customers/&lt;id&gt;')</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">get_customer_v1</span>(<span class="hljs-params">id</span>):</span>
    customer = Customer.find(id)
    <span class="hljs-keyword">return</span> jsonify({
        <span class="hljs-string">'id'</span>: customer.id,
        <span class="hljs-string">'name'</span>: customer.name,
        <span class="hljs-string">'address'</span>: customer.address  <span class="hljs-comment"># Old format</span>
    })

<span class="hljs-meta">@app.route('/api/v2/customers/&lt;id&gt;')</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">get_customer_v2</span>(<span class="hljs-params">id</span>):</span>
    customer = Customer.find(id)
    <span class="hljs-keyword">return</span> jsonify({
        <span class="hljs-string">'id'</span>: customer.id,
        <span class="hljs-string">'name'</span>: customer.name,
        <span class="hljs-string">'address'</span>: {  <span class="hljs-comment"># New structured format</span>
            <span class="hljs-string">'street'</span>: customer.street_address,
            <span class="hljs-string">'city'</span>: customer.city,
            <span class="hljs-string">'state'</span>: customer.state,
            <span class="hljs-string">'zip'</span>: customer.zip_code
        }
    })
</code></pre>
<p>To implement this, you can initially deploy both endpoints with blue-green. Then monitor usage of v1 endpoint over time. Once v1 traffic drops below 1% (meaning clients have migrated), deprecate it formally. Remove v1 endpoint in a subsequent release, not during the blue-green deployment itself.</p>
<p>Announce the new API version to consumers with a migration timeline. Give them 2-3 months to update their integrations. Send reminder emails at the halfway point and 2 weeks before v1 shutdown.</p>
<h3 id="heading-monitor-both-environments">Monitor Both Environments</h3>
<p>During the transition period, both blue and green are production environments serving real traffic. Monitor them separately to detect version-specific issues.</p>
<p>Set up separate CloudWatch dashboards for blue and green target groups with the same metrics arranged identically. This makes it easy to spot differences at a glance. If green's response time is 200ms while blue's is 50ms, that's a red flag.</p>
<h4 id="heading-alert-on-metric-divergence">Alert on metric divergence</h4>
<p>Create alarms that trigger when green's metrics deviate significantly from blue's baseline. For example, if green's error rate is more than 2x blue's historical average, trigger an alert. If green's database query time is 50% higher, investigate before shifting more traffic.</p>
<h4 id="heading-log-aggregation">Log aggregation</h4>
<p>Ensure logs from both environments are tagged with their version (<code>environment: blue</code> or <code>environment: green</code>) so you can filter and compare them. Use CloudWatch Insights queries to spot patterns.</p>
<h2 id="heading-when-not-to-use-blue-green">When NOT to Use Blue-Green</h2>
<p>Blue-green isn't always the right choice. Avoid it when you have:</p>
<ul>
<li><p><strong>Very large database migrations</strong>: If your migration takes hours or requires significant locks, use a traditional maintenance window.</p>
</li>
<li><p><strong>Highly stateful applications</strong>: Real-time collaboration tools or WebSocket applications with complex in-memory state may need rolling deployments instead.</p>
</li>
<li><p><strong>Cost constraints</strong>: Running two environments doubles costs. Consider canary deployments for cost-sensitive applications.</p>
</li>
<li><p><strong>Complex data model redesigns</strong>: Use the strangler fig pattern to gradually migrate functionality to a new service.</p>
</li>
</ul>
<h3 id="heading-alternative-deployment-strategies">Alternative Deployment Strategies</h3>
<h4 id="heading-canary-deployments">Canary Deployments</h4>
<p>Route a small percentage (5-10%) to the new version:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"trafficRouting"</span>: {
    <span class="hljs-attr">"type"</span>: <span class="hljs-string">"TimeBasedCanary"</span>,
    <span class="hljs-attr">"timeBasedCanary"</span>: {
      <span class="hljs-attr">"canaryPercentage"</span>: <span class="hljs-number">10</span>,
      <span class="hljs-attr">"canaryInterval"</span>: <span class="hljs-number">5</span>
    }
  }
}
</code></pre>
<h3 id="heading-rolling-deployments">Rolling Deployments</h3>
<p>Gradually replace old tasks with new ones:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"deploymentConfiguration"</span>: {
    <span class="hljs-attr">"maximumPercent"</span>: <span class="hljs-number">200</span>,
    <span class="hljs-attr">"minimumHealthyPercent"</span>: <span class="hljs-number">100</span>
  }
}
</code></pre>
<h2 id="heading-cleanup">Cleanup</h2>
<p>After you've successfully completed your blue-green deployment, validated the green environment, and run the contract phase, you need to clean up the AWS resources to avoid unnecessary costs and resource sprawl.</p>
<p><strong>What you're removing</strong>:</p>
<ul>
<li><p>The entire infrastructure stack (VPC, subnets, NAT gateways, load balancer, ECS cluster, RDS database, and all associated resources)</p>
</li>
<li><p>This is appropriate for a tutorial/testing scenario where you deployed everything from scratch</p>
</li>
</ul>
<p>Important considerations before cleanup:</p>
<ul>
<li><p>Ensure you have backups if you need to reference any data later</p>
</li>
<li><p>Export any logs or metrics you want to retain</p>
</li>
<li><p>Document lessons learned from the deployment</p>
</li>
<li><p>Verify no production traffic is still using these resources</p>
</li>
</ul>
<pre><code class="lang-bash"><span class="hljs-built_in">cd</span> terraform

<span class="hljs-comment"># Terraform will prompt you to confirm with "yes"</span>
<span class="hljs-comment"># Review the destruction plan carefully before confirming</span>
terraform destroy  <span class="hljs-comment"># Takes ~10-15 minutes</span>
</code></pre>
<p><strong>Partial cleanup</strong>: If you want to keep certain resources (like RDS snapshots for reference), you can remove them from Terraform state before destroying:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Remove RDS from Terraform management before destroying</span>
terraform state rm aws_db_instance.main
terraform destroy  <span class="hljs-comment"># Now destroys everything except RDS</span>
</code></pre>
<p>For production environments, you would NOT destroy everything. Instead, you'd decommission the blue environment specifically after confirming green is stable:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Production scenario - remove only blue environment</span>
terraform destroy -target=aws_ecs_service.blue
terraform destroy -target=aws_lb_target_group.blue
</code></pre>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Blue-green deployments with databases require careful planning, but the expand-contract pattern makes it manageable.</p>
<p>Here are some key takeaways:</p>
<ol>
<li><p><strong>Use expand-contract as default</strong> – Maintains backwards compatibility and safe rollbacks.</p>
</li>
<li><p><strong>Externalize state</strong> – Sessions, caches, and storage should use external services.</p>
</li>
<li><p><strong>Plan for three phases</strong> – Don't rush to the contract phase.</p>
</li>
<li><p><strong>Test everything in staging</strong> – Mirror production scale and complexity.</p>
</li>
<li><p><strong>Monitor aggressively</strong> – Track technical and business metrics for both environments.</p>
</li>
<li><p><strong>Know when to use alternatives</strong> – Blue-green isn't always the answer.</p>
</li>
<li><p><strong>Document rollback procedures</strong> – Everyone should know the rollback process before deployment.</p>
</li>
</ol>
<p>The expand-contract pattern requires more work upfront, but this investment pays dividends in reduced risk and maintained uptime. With the strategies and complete implementation provided here, you can successfully deploy even complex, stateful applications with confidence.</p>
<p>As always, I hope you enjoyed this guide and learned something. If you want to stay connected or see more hands-on DevOps content, you can follow me on <a target="_blank" href="https://www.linkedin.com/in/destiny-erhabor">LinkedIn</a>.</p>
<p>For more practical hands-on Cloud/DevOps projects like this one, follow and star this repository: <a target="_blank" href="https://github.com/Caesarsage/Learn-DevOps-by-building">Learn-DevOps-by-building</a>.</p>
<h2 id="heading-further-resources">Further Resources</h2>
<ul>
<li><p>Complete Code: <a target="_blank" href="https://github.com/Caesarsage/bluegreen-deployment-ecs">github.com/Caesarsage/bluegreen-deployment-ecs</a></p>
</li>
<li><p>Learn DevOps by Building: <a target="_blank" href="https://github.com/Caesarsage/Learn-DevOps-by-building">GitHub repo</a></p>
</li>
<li><p>AWS ECS Blue/Green Documentation: <a target="_blank" href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/deployment-type-bluegreen.html">AWS Docs</a></p>
</li>
<li><p>AWS CodeDeploy for ECS: <a target="_blank" href="https://docs.aws.amazon.com/codedeploy/latest/userguide/deployment-steps-ecs.html">AWS Docs</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How Relational Database Constraints Work and Why They're Important ]]>
                </title>
                <description>
                    <![CDATA[ Databases are a crucial tool because they store the data that power our day-to-day lives. Databases are designed to match the real world as much as possible, so they store data of different forms, about different things, just as it is in the world. T... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-relational-database-constraints-work-and-why-theyre-important/</link>
                <guid isPermaLink="false">69681d224dcb07c08e435626</guid>
                
                    <category>
                        <![CDATA[ Databases ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Relational Database ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zubair Idris Aweda ]]>
                </dc:creator>
                <pubDate>Wed, 14 Jan 2026 22:48:02 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1768416017042/66390973-a4cb-4e7a-9161-2d737045bf7b.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Databases are a crucial tool because they store the data that power our day-to-day lives. Databases are designed to match the real world as much as possible, so they store data of different forms, about different things, just as it is in the world.</p>
<p>There are many rules that govern how entities interact with each other, to make things work. For example, a student can’t take a course that the school doesn’t offer. A soccer player can’t have a jersey number less than 1 or greater than 99. And a car must always have a plate number.</p>
<p>Relational databases are also able to represent and enforce these rules using <strong>constraints</strong>. And in this article, I’ll explain how constraints work with practical examples.</p>
<p>Whether you’re a beginner or just looking to refresh your knowledge, this article will help you learn the essentials. If you need some more background, you can read this article on the <a target="_blank" href="https://www.freecodecamp.org/news/learn-relational-database-basics-key-concepts-for-beginners/">basics of relational databases</a> before continuing.</p>
<h3 id="heading-what-well-cover">What We’ll Cover:</h3>
<ol>
<li><p><a class="post-section-overview" href="#heading-what-is-a-relational-database-constraint">What is a Relational Database Constraint?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-types-of-relational-database-constraints">Types of Relational Database Constraints</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-inherent-model-based-constraints-implicit-constraints">Inherent Model-based Constraints (Implicit Constraints)</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-schema-based-constraints-explicit-constraints">Schema-based Constraints (Explicit Constraints)</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-application-based-constraints-semantic-constraints">Application-based constraints (Semantic constraints)</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-testing-constraints">Testing Constraints</a></p>
<ul>
<li><a class="post-section-overview" href="#heading-how-to-delete-a-record">How to Delete a Record</a></li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-summary">Summary</a></p>
</li>
</ol>
<h2 id="heading-what-is-a-relational-database-constraint">What is a Relational Database Constraint?</h2>
<p>Relational database constraints are a set of database rules that are used to define or determine what set of values are acceptable or valid in a database. They’re usually based on the many rules of the real world.</p>
<p>They are put in place to:</p>
<ul>
<li><p>Ensure data accuracy: only values that would be acceptable in real life should be acceptable in the database. Learn more about data accuracy <a target="_blank" href="https://www.ibm.com/think/topics/data-accuracy">here</a>.</p>
</li>
<li><p>Ensure data integrity: values in the database remain correct, accurate, complete, and valid as long as the database exists. Learn more about data integrity <a target="_blank" href="https://www.fortinet.com/uk/resources/cyberglossary/data-integrity">here</a>.</p>
</li>
<li><p>Ensure data consistency: values always maintain same agreed form throughout their lifetime.</p>
</li>
</ul>
<p>These rules limit what can be entered into a database or what can be deleted from it. They also limit data update to ensure validity after original creation.</p>
<blockquote>
<p>These integrity constraints help enforce business rules on data in the tables to ensure the accuracy and reliability of the data. - <a target="_blank" href="https://aws.amazon.com/rds/what-is-a-relational-database/">AWS</a></p>
</blockquote>
<h2 id="heading-types-of-relational-database-constraints">Types of Relational Database Constraints</h2>
<p>There are many ways to group or categorise database constraints, depending on how they’re applied or what they’re preventing. This article focuses on three popular types:</p>
<ul>
<li><p>Inherent model-based constraints (implicit constraints)</p>
</li>
<li><p>Schema-based constraints (explicit constraints)</p>
</li>
<li><p>Application-based constraints (semantic constraints)</p>
</li>
</ul>
<h3 id="heading-inherent-model-based-constraints-implicit-constraints">Inherent Model-based Constraints (Implicit Constraints)</h3>
<p>These rules are the base rules that come with the database and are enforced by the DMBS. Some of these rules are:</p>
<ul>
<li><p>Each row must be unique. This is with or without a <code>UNIQUE</code> or <code>PRIMARY KEY</code> constraint.</p>
</li>
<li><p>Columns can only store one value at a time. The value of a field like <code>age</code> will always be one value like 23, not 23 and 35.</p>
</li>
<li><p>Each column name in a table must be unique.</p>
</li>
<li><p>Columns exist for all rows. Every row will have the same number of columns. For some of the rows, the data might be empty, but the column will always be there.</p>
</li>
</ul>
<h3 id="heading-schema-based-constraints-explicit-constraints">Schema-based Constraints (Explicit Constraints)</h3>
<p>These constraints are expressed by the developer or database designer on database creation. They’re expressed directly in the database schemas, using the <a target="_blank" href="https://en.wikipedia.org/wiki/Data_definition_language">DDL</a>.</p>
<p>These can be further broken down into:</p>
<ul>
<li><p>Domain constraints</p>
</li>
<li><p>Key constraints</p>
<ul>
<li><p>Entity integrity constraint (Primary key)</p>
</li>
<li><p>Unique constraint (Unique key)</p>
</li>
<li><p>Referential integrity constraint (Foreign key)</p>
</li>
</ul>
</li>
</ul>
<h4 id="heading-1-domain-constraints">1. Domain Constraints</h4>
<p>These are used to define a range or set of possible values for an attribute of a database table. They help ensure that column values are valid and consistent by defining acceptable data types, formats, and ranges for an attribute. This prevents incorrect or illogical data entry and maintains data integrity.</p>
<p>You can define them simply by specifying a data type that the values must follow. For example, the <code>age</code> of a person can only be a number, or could be a number between 18-60 if the database is for a company, or a number between 5-65 if it’s for an amusement park.</p>
<p>The database will enforce this rule by rejecting age values outside of the given range or type. The DDL for the age would look like this:</p>
<pre><code class="lang-sql"><span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> people (
    age <span class="hljs-built_in">INT</span>, <span class="hljs-comment">-- Any integer value is allowed</span>
    age <span class="hljs-built_in">INT</span> <span class="hljs-keyword">CHECK</span> (age <span class="hljs-keyword">BETWEEN</span> <span class="hljs-number">18</span> <span class="hljs-keyword">AND</span> <span class="hljs-number">60</span>), <span class="hljs-comment">-- Only allows ages between 18 and 60</span>
    age <span class="hljs-built_in">INT</span> <span class="hljs-keyword">CHECK</span> (age <span class="hljs-keyword">BETWEEN</span> <span class="hljs-number">5</span> <span class="hljs-keyword">AND</span> <span class="hljs-number">65</span>) <span class="hljs-comment">-- Only allows ages between 5 and 65</span>
);
</code></pre>
<p>The <code>INT</code> means only integer values are accepted, and the <code>CHECK</code> is used with the <code>BETWEEN</code> and <code>AND</code> keywords to specify the sub-domain or range of values.</p>
<p>Other <a target="_blank" href="https://www.w3schools.com/sql/sql_datatypes.asp">data types in SQL</a> include: <code>CHAR</code>, <code>BIT</code>, <code>DATE</code>, <code>VARCHAR</code> and so on. You can use all of them to define the acceptable domain for database values.</p>
<pre><code class="lang-sql"><span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> employees (
    employee_id <span class="hljs-built_in">INT</span>,
    <span class="hljs-keyword">name</span> <span class="hljs-built_in">VARCHAR</span>(<span class="hljs-number">100</span>),
    age <span class="hljs-built_in">INT</span> <span class="hljs-keyword">CHECK</span> (age <span class="hljs-keyword">BETWEEN</span> <span class="hljs-number">18</span> <span class="hljs-keyword">AND</span> <span class="hljs-number">60</span>)
);
</code></pre>
<p>As well as defining a range of acceptable values, you can also define the optionality of an attribute using the <code>NOT NULL</code> keyword. You’d use this in cases where the data must exist and must also be within the given range.</p>
<pre><code class="lang-sql"><span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> employees (
    employee_id <span class="hljs-built_in">INT</span> <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>,
    <span class="hljs-keyword">name</span> <span class="hljs-built_in">VARCHAR</span>(<span class="hljs-number">100</span>) <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>,
    age <span class="hljs-built_in">INT</span> <span class="hljs-keyword">CHECK</span> (age <span class="hljs-keyword">BETWEEN</span> <span class="hljs-number">18</span> <span class="hljs-keyword">AND</span> <span class="hljs-number">60</span>)
);
</code></pre>
<p>In this example, every employee record needs to have an <code>employee_id</code> and a <code>name</code> but not an <code>age</code>. This works for real life situations where, although the range of values is known, the actual value is either unknown or doesn’t exist. An example would be the minor course of study of a student at a university – many students only have majors, and as such, the minor course of study will be empty (NULL) for those students.</p>
<h4 id="heading-2-entity-integrity-constraint-primary-key">2. Entity integrity constraint (Primary key)</h4>
<p>This ensures that no primary key is NULL. The primary key is the one attribute or set of attributes that must be unique to each row in the database. It’s the primary value that uniquely identifies the rest of the data. This means that every row in the database will remain uniquely identifiable with a primary key.</p>
<p>A NULL primary key means that rows will not be unique, or identifiable, and the database can contain duplicates. Without the primary key, we can’t have data consistency.</p>
<p>For example, in a school, every student will have a unique student id number with which they can always be distinguished from other students. The government uses methods like passport numbers or tax ids to uniquely identify citizens.</p>
<p>In our example, it’s impossible to be a student without a student id number. You can implement this constraint by using the <code>PRIMARY KEY</code> keyword.</p>
<pre><code class="lang-sql"><span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> employees (
    employee_id <span class="hljs-built_in">INT</span> PRIMARY <span class="hljs-keyword">KEY</span>,
    <span class="hljs-keyword">name</span> <span class="hljs-built_in">VARCHAR</span>(<span class="hljs-number">100</span>),
    age <span class="hljs-built_in">INT</span> <span class="hljs-keyword">CHECK</span> (age <span class="hljs-keyword">BETWEEN</span> <span class="hljs-number">18</span> <span class="hljs-keyword">AND</span> <span class="hljs-number">60</span>)
);
</code></pre>
<h4 id="heading-3-unique-constraint-unique-key">3. Unique constraint (Unique key)</h4>
<p>This is similar to the <strong>Entity integrity constraint</strong> in that it only accepts unique values – but it’s different in that it accepts NULL values.</p>
<p>An example of this would be in a students table, every student must have a student id number that uniquely identifies them. This number cannot be NULL, and it must the unique. Students can also have an email address that the school can reach them on. This email must be unique for each student. But, not every student has to have an email. So the condition is: <strong>“If the value exists, it must be unique”</strong>.</p>
<p>You can implement this constraint using the <code>UNIQUE</code> keyword, like this:</p>
<pre><code class="lang-sql"><span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> students (
    student_id <span class="hljs-built_in">INT</span> PRIMARY <span class="hljs-keyword">KEY</span>, <span class="hljs-comment">-- Must exist and must be unique</span>
    email <span class="hljs-built_in">VARCHAR</span>(<span class="hljs-number">255</span>) <span class="hljs-keyword">UNIQUE</span> <span class="hljs-comment">-- Can be NULL, but must be unique if provided</span>
);
</code></pre>
<h4 id="heading-4-referential-integrity-constraint-foreign-key">4. Referential integrity constraint (Foreign key)</h4>
<p>This constraint guards the relationship between two related tables. It is used to maintain consistency in the relationship. It requires that data from one table, A, being referenced in another table, B, must exist in the original table, A. For example, a student can’t register for a course the school doesn’t have.</p>
<p>To enforce this, the <code>FOREIGN KEY</code> keyword is used with the <code>REFERENCES</code> to define the table being referenced, and what attribute is being referred to.</p>
<pre><code class="lang-sql"><span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> courses (
    course_id <span class="hljs-built_in">INT</span> PRIMARY <span class="hljs-keyword">KEY</span>,
    course_name <span class="hljs-built_in">VARCHAR</span>(<span class="hljs-number">100</span>) <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>
);

<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> students (
    student_id <span class="hljs-built_in">INT</span> PRIMARY <span class="hljs-keyword">KEY</span>,
    student_name <span class="hljs-built_in">VARCHAR</span>(<span class="hljs-number">100</span>) <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>,
    course_id <span class="hljs-built_in">INT</span>,
    <span class="hljs-keyword">FOREIGN</span> <span class="hljs-keyword">KEY</span> (course_id) <span class="hljs-keyword">REFERENCES</span> courses(course_id)
);
</code></pre>
<p>In this example, every value provided in the <code>course_id</code> of the <code>students</code> must be in the <code>courses</code> table.</p>
<h3 id="heading-application-based-constraints-semantic-constraints">Application-based constraints (Semantic constraints)</h3>
<p>These can also be called <strong>business rules</strong>. They can’t be directly expressed in the database schema, so they’re often implemented the application layer instead.</p>
<p>These are logical constraints, like saying <strong>“a course cannot have more than 30 students enrolled”</strong> or <strong>“a customer cannot place an order if it would exceed their credit limit”</strong>.</p>
<p>These rules are best implemented in the application, because it would be too complex (or sometimes impossible) to implement them on the database itself.</p>
<h2 id="heading-testing-constraints">Testing Constraints</h2>
<p>To demonstrate the constraints we’ve discussed here, let’s look at this sample school database setup:</p>
<pre><code class="lang-sql"><span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> courses (course_id <span class="hljs-built_in">INT</span> PRIMARY <span class="hljs-keyword">KEY</span>, course_name <span class="hljs-built_in">VARCHAR</span>(<span class="hljs-number">100</span>) <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>, max_students <span class="hljs-built_in">INT</span> <span class="hljs-keyword">CHECK</span> (max_students &gt; <span class="hljs-number">0</span>));

<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> students (student_id <span class="hljs-built_in">INT</span> PRIMARY <span class="hljs-keyword">KEY</span>, student_name <span class="hljs-built_in">VARCHAR</span>(<span class="hljs-number">100</span>) <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>, email <span class="hljs-built_in">VARCHAR</span>(<span class="hljs-number">100</span>) <span class="hljs-keyword">UNIQUE</span>, age <span class="hljs-built_in">INT</span> <span class="hljs-keyword">CHECK</span> (age <span class="hljs-keyword">BETWEEN</span> <span class="hljs-number">5</span> <span class="hljs-keyword">AND</span> <span class="hljs-number">25</span>));

<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> enrollments (
    enrollment_id <span class="hljs-built_in">INT</span> PRIMARY <span class="hljs-keyword">KEY</span>,
    student_id <span class="hljs-built_in">INT</span> <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>,
    course_id <span class="hljs-built_in">INT</span> <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>,
    enrollment_date <span class="hljs-built_in">DATE</span> <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>,
    <span class="hljs-keyword">FOREIGN</span> <span class="hljs-keyword">KEY</span> (student_id) <span class="hljs-keyword">REFERENCES</span> students (student_id),
    <span class="hljs-keyword">FOREIGN</span> <span class="hljs-keyword">KEY</span> (course_id) <span class="hljs-keyword">REFERENCES</span> courses (course_id)
);
</code></pre>
<p>This shows the creation of a sample school database with three tables: <code>courses</code>, <code>students</code>, and <code>enrollments</code>.</p>
<p>The <code>courses</code> table includes a primary key for course IDs, course names, and a constraint ensuring that the maximum number of students is greater than zero. The <code>students</code> table contains a primary key for student IDs, student names, unique email addresses, and an age constraint between 5 and 25. The <code>enrollments</code> table links students to courses with primary keys for enrollment IDs and foreign keys referencing the <code>students</code> and <code>courses</code> tables, along with a non-null enrollment date.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768410057148/ffe42706-4540-4ddb-8394-c157fe999b96.png" alt="DDL to create database tables" class="image--center mx-auto" width="2310" height="628" loading="lazy"></p>
<p>At this point, the tables are created, and setup with the constraints guiding them.</p>
<p>Now we’ll test a few queries:</p>
<ol>
<li>Insert courses, Mathematics and History, into the <code>courses</code> table:</li>
</ol>
<pre><code class="lang-sql"><span class="hljs-keyword">INSERT</span> <span class="hljs-keyword">INTO</span> courses (course_id, course_name, max_students) <span class="hljs-keyword">VALUES</span> (<span class="hljs-number">1</span>, <span class="hljs-string">'Mathematics'</span>, <span class="hljs-number">30</span>);
<span class="hljs-keyword">INSERT</span> <span class="hljs-keyword">INTO</span>
    courses (course_id, course_name, max_students)
<span class="hljs-keyword">VALUES</span>
    (<span class="hljs-number">2</span>, <span class="hljs-string">'History'</span>, <span class="hljs-number">25</span>);
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768410234586/bb08fb52-9573-4f57-88d2-6383138ddc7f.png" alt="Query to insert courses" class="image--center mx-auto" width="2310" height="392" loading="lazy"></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768410261770/f974693c-25fc-4952-80c7-6db7f4959236.png" alt="Result of insert query" class="image--center mx-auto" width="2310" height="162" loading="lazy"></p>
<p>The query works perfectly, as the records get inserted.</p>
<ol start="2">
<li>Insert students, Alice and Bob, into the <code>students</code> table:</li>
</ol>
<pre><code class="lang-sql"><span class="hljs-keyword">INSERT</span> <span class="hljs-keyword">INTO</span>
    students (student_id, student_name, email, age)
<span class="hljs-keyword">VALUES</span>
    (<span class="hljs-number">101</span>, <span class="hljs-string">'Alice'</span>, <span class="hljs-string">'alice@example.com'</span>, <span class="hljs-number">20</span>);

<span class="hljs-keyword">INSERT</span> <span class="hljs-keyword">INTO</span>
    students (student_id, student_name, email, age)
<span class="hljs-keyword">VALUES</span>
    (<span class="hljs-number">102</span>, <span class="hljs-string">'Bob'</span>, <span class="hljs-literal">NULL</span>, <span class="hljs-number">18</span>);
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768410360967/92aee819-63fe-4b3c-af2b-b579d66e58a9.png" alt="Query to insert students" class="image--center mx-auto" width="2310" height="532" loading="lazy"></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768410407790/ec9893cf-ded6-4ec2-bac1-c78d430a84fb.png" alt="Result of query" class="image--center mx-auto" width="2310" height="158" loading="lazy"></p>
<p>The query works perfectly, as the records get inserted.</p>
<ol start="3">
<li>Enroll Alice into Mathematics:</li>
</ol>
<pre><code class="lang-sql"><span class="hljs-keyword">INSERT</span> <span class="hljs-keyword">INTO</span>
    enrollments (enrollment_id, student_id, course_id, enrollment_date)
<span class="hljs-keyword">VALUES</span>
    (<span class="hljs-number">1001</span>, <span class="hljs-number">101</span>, <span class="hljs-number">1</span>, <span class="hljs-string">'2026-01-14'</span>);
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768410510198/be229077-028c-4048-836c-fb7796b02f7d.png" alt="Query to insert enrollment" class="image--center mx-auto" width="2310" height="290" loading="lazy"></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768410543235/f546d371-da31-4f42-be3d-20bd2bb1aa3a.png" alt="Result of query" class="image--center mx-auto" width="2310" height="114" loading="lazy"></p>
<p>The query works perfectly, as the record gets inserted.</p>
<ol start="4">
<li>Insert a new student, Charlie, into the <code>students</code> table:</li>
</ol>
<pre><code class="lang-sql"><span class="hljs-keyword">INSERT</span> <span class="hljs-keyword">INTO</span>
    students (student_id, student_name, email, age)
<span class="hljs-keyword">VALUES</span>
    (<span class="hljs-number">103</span>, <span class="hljs-string">'Charlie'</span>, <span class="hljs-string">'charlie@example.com'</span>, <span class="hljs-number">30</span>);
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768410642880/0b9f038d-fb4f-49e1-a92f-3c1fd01c3fae.png" alt="Failed query to insert student" class="image--center mx-auto" width="2310" height="290" loading="lazy"></p>
<p>This fails because Charlie has an <code>age</code> value of 30, which is outside of the specified range of <code>age INT CHECK (age BETWEEN 5 AND 25)</code>. The record of Charlie never gets added.</p>
<p>Here’s a list of some other queries that will fail:</p>
<pre><code class="lang-sql"><span class="hljs-keyword">INSERT</span> <span class="hljs-keyword">INTO</span>
    students (student_id, student_name, email, age)
<span class="hljs-keyword">VALUES</span>
    (<span class="hljs-number">104</span>, <span class="hljs-string">'David'</span>, <span class="hljs-string">'alice@example.com'</span>, <span class="hljs-number">19</span>); <span class="hljs-comment">-- Fails for duplicate email</span>

<span class="hljs-keyword">INSERT</span> <span class="hljs-keyword">INTO</span>
    students (student_id, student_name, email, age)
<span class="hljs-keyword">VALUES</span>
    (<span class="hljs-literal">NULL</span>, <span class="hljs-string">'Evra'</span>, <span class="hljs-string">'evra@example.com'</span>, <span class="hljs-number">20</span>); <span class="hljs-comment">-- Fails for NULL primary key</span>

<span class="hljs-keyword">INSERT</span> <span class="hljs-keyword">INTO</span>
    enrollments (enrollment_id, student_id, course_id, enrollment_date)
<span class="hljs-keyword">VALUES</span>
    (<span class="hljs-number">1002</span>, <span class="hljs-number">999</span>, <span class="hljs-number">1</span>, <span class="hljs-string">'2026-01-14'</span>); <span class="hljs-comment">-- Fails for invalid student reference</span>
</code></pre>
<p>In each case, the DBMS will provide a reason for the rejection or failure.</p>
<ol start="5">
<li>Delete Bob from the <code>students</code> table:</li>
</ol>
<pre><code class="lang-sql"><span class="hljs-keyword">DELETE</span> <span class="hljs-keyword">FROM</span> students
<span class="hljs-keyword">WHERE</span>
    student_id = <span class="hljs-number">102</span>;
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768411198451/3e33efdb-68e9-4cf5-a809-d2053059c29d.png" alt="Query to delete student" class="image--center mx-auto" width="2310" height="260" loading="lazy"></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768411236739/50b24978-2612-4232-8169-3a24377b39a0.png" alt="Result of query" class="image--center mx-auto" width="2310" height="114" loading="lazy"></p>
<p>The query works perfectly, as the record gets deleted.</p>
<ol start="6">
<li>Delete Alice from the <code>students</code> table:</li>
</ol>
<pre><code class="lang-sql"><span class="hljs-keyword">DELETE</span> <span class="hljs-keyword">FROM</span> students
<span class="hljs-keyword">WHERE</span>
    student_id = <span class="hljs-number">101</span>; <span class="hljs-comment">-- Fails for referential integrity constraint</span>
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768411336408/8fc634c2-1f10-41f2-b413-4c6b5af22369.png" alt="Failed query to delete students" class="image--center mx-auto" width="2310" height="278" loading="lazy"></p>
<p>This fails because Alice, with <code>student_id</code> of 101, has an enrollment record in the <code>enrollments</code> table. Deleting the record would mean there will be an enrollment record for a non-existent student which should not be possible.</p>
<h3 id="heading-how-to-delete-a-record">How to Delete a Record</h3>
<p>In some cases, you do want to delete a record, even though it has records tied to it. There are two main ways to go about this:</p>
<h4 id="heading-cascade">CASCADE</h4>
<p>You can use this to define situations where, when a parent record is deleted, the child records cannot exist. All dependent (child) records in other tables are <strong>automatically deleted</strong>. You can use this to ensure that all enrollment records are deleted when the course is no longer available, or when a student is no longer in the school.</p>
<pre><code class="lang-sql"><span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> enrollments (enrollment_id <span class="hljs-built_in">INT</span> PRIMARY <span class="hljs-keyword">KEY</span>, student_id <span class="hljs-built_in">INT</span> <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>, course_id <span class="hljs-built_in">INT</span> <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>, <span class="hljs-keyword">FOREIGN</span> <span class="hljs-keyword">KEY</span> (course_id) <span class="hljs-keyword">REFERENCES</span> courses (course_id) <span class="hljs-keyword">ON</span> <span class="hljs-keyword">DELETE</span> <span class="hljs-keyword">CASCADE</span>);

<span class="hljs-keyword">DELETE</span> <span class="hljs-keyword">FROM</span> courses
<span class="hljs-keyword">WHERE</span>
    course_id = <span class="hljs-number">1</span>;
</code></pre>
<h4 id="heading-set-null-or-set-default">SET NULL or SET DEFAULT</h4>
<p>You can use these methods to define situations where child records can still exist without the parent. All dependent (child) records in other tables are <strong>automatically set to null</strong> or <strong>automatically set to a defined default.</strong></p>
<p>A useful example is if a school had a mentor assigned to students, when the mentor leaves the school, you don’t want to delete the students – you want to set the mentor to NULL or a default staff.</p>
<pre><code class="lang-sql"><span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> teachers (teacher_id <span class="hljs-built_in">INT</span> PRIMARY <span class="hljs-keyword">KEY</span>, teacher_name <span class="hljs-built_in">VARCHAR</span>(<span class="hljs-number">100</span>) <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>);

<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> students (student_id <span class="hljs-built_in">INT</span> PRIMARY <span class="hljs-keyword">KEY</span>, student_name <span class="hljs-built_in">VARCHAR</span>(<span class="hljs-number">100</span>) <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>, mentor_id <span class="hljs-built_in">INT</span>, <span class="hljs-keyword">FOREIGN</span> <span class="hljs-keyword">KEY</span> (mentor_id) <span class="hljs-keyword">REFERENCES</span> teachers (teacher_id) <span class="hljs-keyword">ON</span> <span class="hljs-keyword">DELETE</span> <span class="hljs-keyword">SET</span> <span class="hljs-literal">NULL</span>);
</code></pre>
<ol start="7">
<li>Update Alice’s details. Change her email to a new one, and increase her age:</li>
</ol>
<pre><code class="lang-sql"><span class="hljs-keyword">UPDATE</span> students
<span class="hljs-keyword">SET</span>
    email = <span class="hljs-string">'alice.new@example.com'</span>,
    age = <span class="hljs-number">22</span>
<span class="hljs-keyword">WHERE</span>
    student_id = <span class="hljs-number">101</span>;
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768411782761/0fe14052-2bbd-4c44-952d-a13ce30947ce.png" alt="Query to update student" class="image--center mx-auto" width="2310" height="346" loading="lazy"></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768411806702/651e9694-5821-4aa0-b606-98ab361a9637.png" alt="Result of query" class="image--center mx-auto" width="2310" height="112" loading="lazy"></p>
<p>The query works perfectly, as the record gets updated.</p>
<ol start="8">
<li>Update Alice’s age to 30:</li>
</ol>
<pre><code class="lang-sql"><span class="hljs-keyword">UPDATE</span> students
<span class="hljs-keyword">SET</span>
    age = <span class="hljs-number">30</span>
<span class="hljs-keyword">WHERE</span>
    student_id = <span class="hljs-number">101</span>;
</code></pre>
<p>This fails just like the 4th test for the same reason: the <code>age</code> is out of the stated range.</p>
<p>Here’s another query that will fail:</p>
<pre><code class="lang-sql"><span class="hljs-keyword">UPDATE</span> enrollments
<span class="hljs-keyword">SET</span>
    course_id = <span class="hljs-number">999</span>
<span class="hljs-keyword">WHERE</span>
    enrollment_id = <span class="hljs-number">1001</span>;
</code></pre>
<p>This will fail because the new <code>course_id</code> does not exist in the <code>courses</code> table.</p>
<h2 id="heading-summary">Summary</h2>
<p>Databases are a pivotal part of everyday modern technology, and understanding their fundamental concepts can open doors to building and managing more accurate databases.</p>
<p>This article introduced you to what relational database constraints are, some of the different types, and how they’re enforced and violated. You should now have the essential knowledge to navigate the world of database constraints confidently.</p>
<p>If you’re curious to learn more, connect with me on <a target="_blank" href="https://www.linkedin.com/in/idris-aweda-zubair-5433121a3/">LinkedIn</a>, <a target="_blank" href="https://twitter.com/greatzubs">Twitter</a>, or <a target="_blank" href="https://github.com/Zubs">GitHub</a>. Let’s continue this journey together toward mastering database systems!</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
