<?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" version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[Alexander Obregon's Substack]]></title><description><![CDATA[Weekly recaps of what I’ve published, plus updates, extras, and some exclusive content you won’t find on my Medium.]]></description><link>https://alexanderobregon.substack.com</link><image><url>https://substackcdn.com/image/fetch/$s_!4Mj9!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Falexanderobregon.substack.com%2Fimg%2Fsubstack.png</url><title>Alexander Obregon&apos;s Substack</title><link>https://alexanderobregon.substack.com</link></image><generator>Substack</generator><lastBuildDate>Wed, 04 Nov 2026 09:11:41 GMT</lastBuildDate><atom:link href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Ffeed" rel="self" type="application/rss+xml"/><copyright><![CDATA[Alexander Obregon]]></copyright><language><![CDATA[en]]></language><webMaster><![CDATA[alexanderobregon@substack.com]]></webMaster><itunes:owner><itunes:email><![CDATA[alexanderobregon@substack.com]]></itunes:email><itunes:name><![CDATA[Alexander Obregon]]></itunes:name></itunes:owner><itunes:author><![CDATA[Alexander Obregon]]></itunes:author><googleplay:owner><![CDATA[alexanderobregon@substack.com]]></googleplay:owner><googleplay:email><![CDATA[alexanderobregon@substack.com]]></googleplay:email><googleplay:author><![CDATA[Alexander Obregon]]></googleplay:author><itunes:block><![CDATA[Yes]]></itunes:block><item><title><![CDATA[How to Call REST APIs with RestClient in Spring Boot 4]]></title><description><![CDATA[Calling external REST APIs allows a Spring Boot application to retrieve information, submit data, and communicate with other services over HTTP.]]></description><link>https://alexanderobregon.substack.com/p/how-to-call-rest-apis-with-restclient</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/how-to-call-rest-apis-with-restclient</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Mon, 02 Nov 2026 17:24:35 GMT</pubDate><enclosure url="iframe.php?url=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F857991aa-8981-4e6a-857e-05f963674117_480x480.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21fRTM%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fce84d692-2e9d-4454-bc98-63b471d0fd2b_800x444.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!fRTM!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fce84d692-2e9d-4454-bc98-63b471d0fd2b_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!fRTM!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fce84d692-2e9d-4454-bc98-63b471d0fd2b_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!fRTM!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fce84d692-2e9d-4454-bc98-63b471d0fd2b_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!fRTM!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fce84d692-2e9d-4454-bc98-63b471d0fd2b_800x444.jpeg 1456w" sizes="100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21fRTM%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fce84d692-2e9d-4454-bc98-63b471d0fd2b_800x444.jpeg" width="800" height="444" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/ce84d692-2e9d-4454-bc98-63b471d0fd2b_800x444.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:444,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!fRTM!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fce84d692-2e9d-4454-bc98-63b471d0fd2b_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!fRTM!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fce84d692-2e9d-4454-bc98-63b471d0fd2b_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!fRTM!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fce84d692-2e9d-4454-bc98-63b471d0fd2b_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!fRTM!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fce84d692-2e9d-4454-bc98-63b471d0fd2b_800x444.jpeg 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Fspring.io%2Fprojects%2Fspring-boot">Image Source</a></figcaption></figure></div><p>Calling external REST APIs allows a Spring Boot application to retrieve information, submit data, and communicate with other services over HTTP. Spring Boot 4 supports <code>RestClient</code> for making these synchronous requests, providing methods to specify endpoints, add HTTP headers, and send JSON request bodies. When a response comes back, Spring can convert the JSON into a Java object, allowing us to access the returned data without manually parsing it. We can also configure connection and read timeouts, handle HTTP errors, and control how the application responds when an external service encounters a problem.</p><h3>Preparing RestClient for API Communication</h3><p>Before sending HTTP requests to an external API, we need to give Spring Boot the necessary dependencies and configure the client that will handle communication. Spring Boot 4 provides an auto-configured <code>RestClient.Builder</code> that we can customize for a particular service, including its base URL and network timeouts. Keeping these settings in a dedicated client also means we don&#8217;t have to repeat the same configuration for every request. With the client configured, the application has a consistent point of communication with the external service.</p><h4>Adding the RestClient Dependency</h4><p>Spring Boot 4 includes a dedicated starter called <code>spring-boot-starter-restclient</code> for synchronous HTTP communication. It provides the Spring components needed to create REST clients, along with support for converting JSON data through Jackson. Rather than adding individual HTTP libraries and configuring their integration manually, we can add the starter and let Spring Boot handle the default configuration.</p><p>For a Maven project, we&#8217;ll add the following dependency to the existing <code>pom.xml</code> file:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;xml&quot;,&quot;nodeId&quot;:&quot;aacbc3af-b510-4aa6-990d-5327731af4b3&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-xml">&lt;dependency&gt;
    &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
    &lt;artifactId&gt;spring-boot-starter-restclient&lt;/artifactId&gt;
&lt;/dependency&gt;</code></pre></div><p>Maven retrieves the dependency and its required libraries during the next project build. We don&#8217;t need to specify the version individually when the project already inherits Spring Boot&#8217;s dependency management through its parent POM or an imported BOM. This keeps the REST client libraries aligned with the Spring Boot version configured for the application, without requiring us to manage their versions individually.</p><p>The dedicated starter is part of Spring Boot 4&#8217;s more modular dependency structure. Earlier Spring Boot applications commonly obtained REST client support through broader web starters that also provided components for building HTTP endpoints. With Spring Boot 4, an application that only communicates with external APIs can include <code>spring-boot-starter-restclient</code> without adding Spring MVC or an embedded web server. If the application also exposes HTTP endpoints through Spring MVC, <code>spring-boot-starter-webmvc</code> provides the components needed for that functionality.</p><p>Within the starter, Spring supplies <code>RestClient</code>, which was introduced in Spring Framework 6.1 and remains the recommended synchronous HTTP client for new imperative code. Spring Framework 7 also deprecated the older <code>RestTemplate</code> API in favor of <code>RestClient</code>. Both clients communicate over HTTP, but <code>RestClient</code> provides a fluent API where request configuration follows a sequence of method calls such as <code>get()</code>, <code>post()</code>, <code>retrieve()</code>, and <code>body()</code>.</p><p>The synchronous behavior is important because the calling thread waits for the HTTP operation to finish before execution continues. If we request information from an external service, the method calling <code>RestClient</code> remains occupied until the request completes or fails. This behavior works well in traditional Spring MVC applications and other imperative Java code where operations execute in sequence.</p><p>Spring Boot also configures the supporting components needed for the client. Its HTTP message converters handle transformations between Java objects and supported content formats, while a <code>ClientHttpRequestFactory</code> provides the underlying HTTP communication. Spring Boot selects the request factory according to the HTTP libraries available on the application&#8217;s classpath, allowing different projects to rely on different HTTP implementations without changing the methods called through <code>RestClient</code>.</p><p>An important part of this integration is Spring Boot&#8217;s <code>RestClient.Builder</code> bean. Rather than constructing every client directly through <code>RestClient.create()</code>, we can inject the auto-configured builder into a Spring-managed component and customize it for the service we&#8217;re contacting. This allows our client to inherit Spring Boot&#8217;s existing HTTP configuration, including registered customizations and supported request factory settings.</p><h4>Configuring the Base URL</h4><p>External REST APIs generally provide a common server address followed by endpoint locations for particular resources. Instead of repeating the entire address for every operation, <code>RestClient</code> allows us to define a base URL that becomes the starting address for requests made through that client.</p><p>For the examples we will be using today, we&#8217;ll communicate with JSONPlaceholder, a public testing API that provides sample resources such as posts, users, and comments. Its address is https://jsonplaceholder.typicode.com, and individual resources are available through endpoints like <code>/posts</code> and <code>/users</code>. Because JSONPlaceholder doesn&#8217;t require authentication for these operations, we can concentrate on configuring the client before introducing request-specific behavior.</p><p>The base URL can be stored in Spring Boot&#8217;s <code>application.properties</code> file:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;de10df00-029b-450c-8afd-1fc62b3870b5&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">posts.api.base-url=https://jsonplaceholder.typicode.com</code></pre></div><p>Spring Boot loads this property when the application starts, making its value available to components managed by the Spring container. The <code>posts.api.base-url</code> name is a custom application property rather than a predefined Spring Boot setting, so we can give it a name that identifies the external service we&#8217;re contacting.</p><p>Keeping the address in configuration is helpful because the remote API location can differ between environments. During development, we may communicate with a testing server, while a production deployment communicates with another address. Each environment can supply its own property value without requiring changes to the Java class responsible for creating the client.</p><p>Next, we&#8217;ll create a service named <code>PostClient</code> that receives the configured address and builds a reusable <code>RestClient</code> instance:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;b186e565-d0cd-4968-a306-4d703bafc3a1&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.apiclient;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

@Service
public class PostClient {

    private final RestClient restClient;

    public PostClient(
            RestClient.Builder builder,
            @Value("${posts.api.base-url}") String baseUrl) {

        this.restClient = builder
                .baseUrl(baseUrl)
                .build();
    }
}</code></pre></div><p>We register <code>PostClient</code> as a Spring-managed component through the <code>@Service</code> annotation, allowing Spring to create the class and supply its constructor dependencies. During construction, Spring injects the auto-configured <code>RestClient.Builder</code> and resolves the <code>${posts.api.base-url}</code> placeholder through <code>@Value</code>. The constructor receives the configured address as a string, so the class doesn&#8217;t need to contain the actual server address.</p><p>Calling <code>baseUrl()</code> assigns that address to the builder, while the following <code>build()</code> call creates the <code>RestClient</code> instance with the selected configuration. We then store the client in the final <code>restClient</code> field, allowing methods inside the service to access the same instance without rebuilding it for every HTTP operation.</p><p>With the configured address, requesting the endpoint <code>/posts/1</code> targets <code>https://jsonplaceholder.typicode.com/posts/1</code>. The base URL supplies the common server address, while the request supplies the location of the resource being accessed. For an API with multiple endpoints, this reduces repeated URL text and keeps the remote server configuration in a single location.</p><p>The <code>RestClient.Builder</code> is stateful, meaning changes made to the builder affect clients subsequently created from that builder. Spring Boot provides prototype-scoped builder instances, so a component receiving its own injected builder can configure it without changing the builders injected into other components. If we need to create several clients from the same builder instance, we can also call <code>clone()</code> to obtain an independent builder before applying further configuration.</p><p>After construction, the <code>RestClient</code> instance can safely be shared across multiple threads. We don&#8217;t need to create a new instance for every HTTP operation, and storing it as a field allows the service to retain the same client configuration throughout its lifetime.</p><p>There&#8217;s also a distinction between configuring a client through Spring Boot&#8217;s injected builder and creating one directly through Spring Framework&#8217;s static factory methods. Although <code>RestClient.create()</code> is supported, a client created that way doesn&#8217;t automatically inherit the customizations applied to Spring Boot&#8217;s injected builder. By receiving the builder through dependency injection, we retain the HTTP configuration already supplied by the application.</p><p>At this point, our service has a reusable REST client associated with a particular external API. Before adding the methods that communicate with its endpoints, we can configure how long the client waits when establishing a connection or receiving data from the server.</p><h4>Configuring Connection Timeouts</h4><p>Network communication introduces delays that aren&#8217;t completely controlled by the application. The remote server could be temporarily unavailable, the network connection could be interrupted, or the service could take longer than expected to respond. Because <code>RestClient</code> performs synchronous requests, the calling thread remains occupied while the HTTP operation is waiting, making timeout configuration an important part of preparing the client. Spring Boot 4 provides configuration properties for controlling connection and read timeouts. These settings determine how long certain network operations are allowed to wait before the HTTP client reports a timeout, giving us control over delays that could leave requests waiting longer than intended.</p><p>We can configure both values through <code>application.properties</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;toml&quot;,&quot;nodeId&quot;:&quot;7d41f2c8-c979-48ce-8632-1959e00ba889&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-toml">spring.http.clients.connect-timeout=3s
spring.http.clients.read-timeout=5s</code></pre></div><p>The <code>connect-timeout</code> property controls the time allowed to establish a connection with the remote server. With a value of three seconds, the client applies that connection limit through its underlying HTTP implementation. If establishing the connection exceeds the permitted duration, the operation can fail with a connection timeout rather than continuing to wait indefinitely.</p><p>The <code>read-timeout</code> property controls how long the HTTP client waits for response data according to the selected HTTP library. In our configuration, the read timeout is five seconds, which becomes relevant when communication has begun but the server hasn&#8217;t delivered the expected data within the permitted interval.</p><p>Connection and read timeouts apply to different stages of HTTP communication. Establishing a connection involves contacting the remote host and opening the connection needed to communicate, while reading occurs during the exchange of response data. The exact behavior of these settings depends on the underlying HTTP client library, including how it measures waiting periods during network activity.</p><p>We also need to distinguish a read timeout from a total request duration limit. Some HTTP implementations apply read timeouts to periods spent waiting for network data rather than the complete duration of an operation. Consequently, setting a five-second read timeout doesn&#8217;t necessarily mean that every HTTP request will finish within five seconds, as the complete operation can involve multiple stages of communication.</p><p>The <code>spring.http.clients</code> property group is the current Spring Boot 4 location for global HTTP client settings. Earlier Spring Boot versions exposed imperative client configuration through <code>spring.http.client</code>, but Spring Boot 4 introduced the plural <code>spring.http.clients</code> group for shared HTTP client configuration.</p><p>These properties apply to Spring Boot&#8217;s auto-configured HTTP clients, including those created through the injected <code>RestClient.Builder</code>. If we&#8217;re communicating with several external APIs through different clients, they can inherit the same timeout values without requiring us to configure each service individually.</p><p>Spring Boot selects the underlying HTTP request factory based on the libraries available to the application. Supported implementations include Apache HttpClient, Jetty HttpClient, Reactor Netty, the JDK HTTP client, and the JDK&#8217;s <code>HttpURLConnection</code> implementation. The selected factory determines how HTTP operations are performed and can affect the exact behavior of the configured timeouts. For some applications, shared timeout values are sufficient, while others communicate with external services that have different response characteristics. An API that typically responds within a few hundred milliseconds may benefit from shorter limits than a service that performs longer processing before returning data. We can account for these differences by configuring individual clients rather than assigning the same timeout values to every external connection.</p><p>Spring Boot provides <code>HttpClientSettings</code> and <code>ClientHttpRequestFactoryBuilder</code> for creating a request factory with settings selected for a particular client. These classes allow us to specify timeout durations directly in Java when the global properties aren&#8217;t appropriate for every service.</p><p>To give our existing <code>PostClient</code> its own timeout configuration, we can replace its constructor with the following version:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;3e97f755-39b7-4983-9c6b-ac51febc1bb6&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public PostClient(
        RestClient.Builder builder,
        @Value("${posts.api.base-url}") String baseUrl) {

    HttpClientSettings settings = HttpClientSettings.defaults()
            .withConnectTimeout(Duration.ofSeconds(3))
            .withReadTimeout(Duration.ofSeconds(5));

    ClientHttpRequestFactory requestFactory =
            ClientHttpRequestFactoryBuilder.detect().build(settings);

    this.restClient = builder
            .baseUrl(baseUrl)
            .requestFactory(requestFactory)
            .build();
}</code></pre></div><p>We begin by calling <code>HttpClientSettings.defaults()</code> to create an object containing the default HTTP client settings. From there, <code>withConnectTimeout()</code> and <code>withReadTimeout()</code> return updated settings containing the durations we&#8217;ve specified. These methods accept Java <code>Duration</code> values, so we can express the time limits through methods such as <code>Duration.ofSeconds()</code> without manually converting between time units.</p><p>After defining the settings, we call <code>ClientHttpRequestFactoryBuilder.detect()</code> to select an appropriate HTTP request factory builder according to the libraries available on the classpath. The following <code>build(settings)</code> call creates a request factory configured with those timeout values. Finally, we pass the factory into <code>requestFactory()</code> before constructing the <code>RestClient</code>, giving this particular client its own HTTP communication settings.</p><p>The replacement constructor requires the following imports alongside those already present in <code>PostClient</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;b5b6e292-e713-4e49-b19c-136dbc8ca3bf&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.time.Duration;

import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder;
import org.springframework.boot.http.client.HttpClientSettings;
import org.springframework.http.client.ClientHttpRequestFactory;</code></pre></div><p>The <code>Duration</code> class represents the timeout intervals, while <code>HttpClientSettings</code> stores the configuration we want to apply. We then rely on <code>ClientHttpRequestFactoryBuilder</code> to construct the request factory, which implements the <code>ClientHttpRequestFactory</code> interface expected by <code>RestClient.Builder</code>. These imports complete the constructor&#8217;s dependencies without requiring changes to the service&#8217;s existing class declaration or its <code>restClient</code> field.</p><p>This configuration allows an individual client to have timeout values that differ from the global properties. If we&#8217;re communicating with several external services, we can give each client settings appropriate for the expected response time without changing the configuration of the others.</p><p>There is an important distinction when assigning an explicit request factory. The custom factory replaces the factory Spring Boot would normally configure for that client, so other HTTP settings that need to remain active must also be accounted for when constructing the replacement. For most applications, configuring the global <code>spring.http.clients</code> properties and retaining the auto-configured request factory requires fewer individual settings.</p><p>Neither connection nor read timeouts automatically cause <code>RestClient</code> to retry a failed operation. If the client exceeds the configured connection or read timeout, the affected HTTP operation can fail with an exception that the calling application needs to handle. The timeout itself doesn&#8217;t instruct the client to repeat the request, and any retry behavior would need its own configuration.</p><p>Keeping timeout settings at the HTTP client level allows us to control how long network operations wait without adding timing logic to individual request methods. Our <code>PostClient</code> now has a configured base URL, a reusable <code>RestClient</code> instance, and connection settings that govern how communication with the external API is performed.</p><h3>Making HTTP Requests with RestClient</h3><p>With our client configured for JSONPlaceholder, we can start communicating with its endpoints through HTTP requests. <code>RestClient</code> provides methods for selecting an HTTP operation, specifying the resource address, supplying request information, and processing the response returned by the server. Spring Boot also handles JSON conversion between Java objects and HTTP content, so we can concentrate on the data being exchanged. The same client can perform different operations depending on the HTTP method and the information required by the external API.</p><h4>Reading API Responses</h4><p>Retrieving information from an external API commonly involves sending an HTTP <code>GET</code> request to an endpoint that identifies the resource we want. The server processes that request and returns an HTTP response containing a status code, headers, and potentially a body with the requested information. When the response contains JSON, <code>RestClient</code> can convert that content into a Java object through Spring&#8217;s configured HTTP message converters.</p><p>JSONPlaceholder provides a <code>/posts/{id}</code> endpoint for retrieving individual posts. Each post contains a user ID, post ID, title, and body, which we can represent through a Java record. This gives Spring a defined type for converting the JSON response into data we can access directly from our application.</p><p>We&#8217;ll begin by creating a record named <code>Post</code> in its own <code>Post.java</code> file:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;18f51d58-8fe9-4d24-ad35-41934382472a&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.apiclient;

public record Post(
        int userId,
        int id,
        String title,
        String body
) {}</code></pre></div><p>The record defines four components matching the property names returned by JSONPlaceholder. When Spring receives the JSON response, its configured Jackson converter maps those properties to the corresponding record components. Spring Boot 4 uses Jackson 3 by default, providing JSON conversion without requiring us to manually read or parse the response content.</p><p>Java records are well suited for representing API responses because they provide an immutable data carrier with generated accessors, a constructor, and standard object methods. We can access the returned values through methods such as <code>id()</code>, <code>title()</code>, and <code>body()</code> without writing additional accessor methods.</p><p>With the record available, we&#8217;ll add a method to our existing <code>PostClient</code> service that retrieves a particular post:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;e1e8f063-8621-45cd-86e6-feb1076401c9&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public Post getPost(int id) {
    return restClient.get()
            .uri("/posts/{id}", id)
            .retrieve()
            .body(Post.class);
}</code></pre></div><p>We begin the request with <code>get()</code>, which selects the HTTP <code>GET</code> method. The following <code>uri()</code> call specifies the endpoint relative to the base URL configured earlier. Spring replaces the <code>{id}</code> placeholder with the value passed into <code>getPost()</code>, allowing the same method to retrieve different posts without manually constructing a complete URL.</p><p>Calling <code>retrieve()</code> prepares the request for response processing. The HTTP operation is executed when we call a terminal method such as <code>body(Post.class)</code>, which reads the response content and converts it into the specified Java type. If we call <code>getPost(1)</code>, the client requests <code>https://jsonplaceholder.typicode.com/posts/1</code> and returns the post information as a <code>Post</code> record. From there, we can access its values through the record&#8217;s generated methods, while the HTTP communication and JSON conversion remain within <code>PostClient</code>.</p><p>The <code>body()</code> method returns <code>null</code> if the response doesn&#8217;t contain a body. For successful responses that contain data, the JSON must also be compatible with the requested Java type. If Spring cannot convert the returned content, the operation fails rather than returning an incorrectly populated object.</p><p>External APIs also commonly return collections of resources rather than individual records. JSONPlaceholder&#8217;s <code>/posts</code> endpoint returns a JSON array containing multiple posts, so we need a Java type that can represent a collection of <code>Post</code> objects.</p><p>We can add a second method to <code>PostClient</code> for retrieving the collection:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;90ad95de-2966-4724-8e11-10aaec987d32&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public List&lt;Post&gt; getPosts() {
    return restClient.get()
            .uri("/posts")
            .retrieve()
            .body(new ParameterizedTypeReference&lt;List&lt;Post&gt;&gt;() {});
}</code></pre></div><p>For this method, we&#8217;ll import <code>java.util.List</code> and <code>org.springframework.core.ParameterizedTypeReference</code>.</p><p>The request follows the same sequence as our individual post retrieval, but the response conversion differs. Instead of requesting a single <code>Post</code>, we&#8217;re requesting a <code>List&lt;Post&gt;</code> containing the objects returned by the API.</p><p>Java&#8217;s type erasure means that passing <code>List.class</code> wouldn&#8217;t preserve the information needed to identify the type of objects inside the collection. <code>ParameterizedTypeReference&lt;List&lt;Post&gt;&gt;</code> retains that generic type information, allowing Spring to convert the JSON array into a list containing <code>Post</code> records.</p><p>Each record corresponds to an entry in the returned JSON array, and we can access those records through ordinary Java collection operations. The same technique applies to other parameterized response types when an API returns collections or data structures containing generic types.</p><p>Sometimes we need more information from the HTTP response than the converted body. External services can include headers containing caching instructions, content information, or other metadata, while the HTTP status tells us how the server handled the request.</p><p>For these situations, <code>RestClient</code> provides <code>toEntity()</code>, which returns a <code>ResponseEntity</code> containing the body along with the HTTP status and headers.</p><p>We&#8217;ll retrieve that information through the following method in <code>PostClient</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;cc951323-10ac-4a79-b552-b00b393593b7&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public ResponseEntity&lt;Post&gt; getPostResponse(int id) {
    return restClient.get()
            .uri("/posts/{id}", id)
            .retrieve()
            .toEntity(Post.class);
}</code></pre></div><p>We need to import <code>org.springframework.http.ResponseEntity</code> for this method. The request still targets an individual post, but calling <code>toEntity(Post.class)</code> changes the returned type from <code>Post</code> to <code>ResponseEntity&lt;Post&gt;</code>. We can retrieve the converted record through <code>getBody()</code>, inspect the HTTP status through <code>getStatusCode()</code>, and access the response headers through <code>getHeaders()</code>.</p><p>This becomes helpful when the response metadata influences what we do next. An API may provide pagination information, caching instructions, or other information through headers, and we can inspect those values while retaining access to the converted response body.</p><p><code>body()</code> and <code>toEntity()</code> follow the default HTTP error handling behavior of <code>RestClient</code>. If the remote API returns an error status, the client normally throws an exception instead of returning the response as an ordinary successful result. We can account for those failures when configuring error handling later in the service.</p><h4>Sending Request Headers</h4><p>Request headers allow us to include information about an HTTP operation beyond its URL and body. External APIs commonly inspect headers to determine which response format the client accepts, identify the calling application, or validate authentication credentials. Spring&#8217;s <code>RestClient</code> provides methods for setting these values directly within the request chain, allowing us to supply the information required by a particular endpoint.</p><p>The <code>Accept</code> header tells the server which content types the client can receive. For a REST API returning JSON, we can specify <code>application/json</code> through the <code>accept()</code> method rather than constructing the header value ourselves.</p><p>Custom headers can also carry information associated with an individual request. Some services support request identifiers that allow requests to be traced across application logs and service boundaries. The exact header names and accepted values depend on the API we&#8217;re contacting.</p><p>To include both a JSON preference and a request identifier, we&#8217;ll add the following method to <code>PostClient</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;60338d3b-edb1-4462-b93e-e19d5715f94f&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public Post getPostWithHeaders(int id, String requestId) {
    return restClient.get()
            .uri("/posts/{id}", id)
            .accept(MediaType.APPLICATION_JSON)
            .header("X-Request-ID", requestId)
            .retrieve()
            .body(Post.class);
}</code></pre></div><p>The method requires <code>org.springframework.http.MediaType</code>, which provides constants for common HTTP media types. We start by selecting the post endpoint, then call <code>accept(MediaType.APPLICATION_JSON)</code> to request a JSON response. The <code>header()</code> method adds <code>X-Request-ID</code> with the identifier passed into the method, allowing each call to provide its own value without changing the client configuration.</p><p>When the request is sent, Spring includes these headers alongside the other HTTP request information. JSONPlaceholder accepts requests without requiring custom identification headers, so the request identifier doesn&#8217;t change the returned post data. APIs that support request tracing can read the identifier and associate it with their own processing records.</p><p>There are situations where multiple headers need to be configured within the same request. <code>RestClient</code> provides <code>headers()</code>, which accepts a callback that gives us access to the request&#8217;s <code>HttpHeaders</code> collection. Through that callback, we can set, replace, or inspect header values before sending the request.</p><p>Authentication is a common reason to use this method. For APIs that require bearer tokens, the <code>HttpHeaders</code> class provides <code>setBearerAuth()</code>, which accepts an access token and places it in the HTTP <code>Authorization</code> header with the expected bearer scheme.</p><p>We can call <code>headers(headers -&gt; headers.setBearerAuth(accessToken))</code> within the request chain, provided that <code>accessToken</code> contains a token obtained through the external service&#8217;s authentication process.</p><p>The token must already be available to the application before the request is made. <code>RestClient</code> doesn&#8217;t obtain or refresh authentication credentials automatically merely because we&#8217;ve configured an authorization header. Authentication requirements and token management depend on the service being contacted.</p><p>JSONPlaceholder doesn&#8217;t require bearer authentication for its public endpoints, but the same header configuration applies to services that validate access tokens.</p><p>When a header value should be included in every request made through a particular client, we can configure it through <code>defaultHeader()</code> on <code>RestClient.Builder</code>. For values that change between calls, setting the header directly within the request method keeps those values associated with the operation that needs them.</p><p>We also need to distinguish the <code>Accept</code> header from <code>Content-Type</code>. The first communicates which response formats the client can receive, while the second identifies the format of content being sent to the server. For requests that submit JSON data, we&#8217;ll provide the content type alongside the request body.</p><h4>Submitting JSON Request Bodies</h4><p>External APIs frequently accept information through HTTP requests that create or modify resources. HTTP <code>POST</code> requests commonly submit data to collection endpoints, while <code>PUT</code> and <code>PATCH</code> are generally used for resource updates. These operations can include a request body containing the information the server needs to process the request. <code>RestClient</code> allows us to provide that body as a Java object, with Spring converting it into the appropriate HTTP content through its configured message converters. When the server expects JSON, we can declare <code>application/json</code> as the request content type and provide an object containing the values we want to submit.</p><p>JSONPlaceholder provides a <code>POST /posts</code> endpoint that accepts information for a new post. The expected request contains a user ID, title, and body, while the server&#8217;s response includes an assigned post ID.</p><p>We&#8217;ll represent the information being submitted through a record named <code>CreatePostRequest</code>, stored in its own <code>CreatePostRequest.java</code> file:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;7e20925c-cfc9-46c6-9e5a-7f673900ab74&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.apiclient;

public record CreatePostRequest(
        int userId,
        String title,
        String body
) {}</code></pre></div><p>This record contains the three values required for the request, without including an ID that would normally be assigned by the server. Its component names correspond to the JSON property names expected by the endpoint, allowing Jackson to serialize the record without custom property mappings.</p><p>The request record and response record serve different purposes. <code>CreatePostRequest</code> represents the information we want to submit, while the existing <code>Post</code> record represents the server&#8217;s response after the operation completes. Keeping these two types distinct also means callers don&#8217;t have to provide a post ID when creating a resource.</p><p>Next, we&#8217;ll add a method to <code>PostClient</code> that accepts the request record and submits it through HTTP:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;bae03230-afb2-4214-8403-f4ecdb8f358b&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public Post createPost(CreatePostRequest request) {
    return restClient.post()
            .uri("/posts")
            .contentType(MediaType.APPLICATION_JSON)
            .accept(MediaType.APPLICATION_JSON)
            .body(request)
            .retrieve()
            .body(Post.class);
}</code></pre></div><p>We begin with <code>post()</code> to select the HTTP <code>POST</code> method, followed by <code>uri("/posts")</code> to identify the collection endpoint receiving the request. The <code>contentType()</code> call declares that we&#8217;re sending JSON, while <code>accept()</code> communicates that we expect JSON in the response.</p><p>Calling <code>body(request)</code> supplies the <code>CreatePostRequest</code> record as the outgoing content. Spring selects a compatible HTTP message converter and serializes the record into JSON before sending the request.</p><p>Notice that <code>body()</code> appears twice in the method, with each occurrence serving a different purpose. The first call belongs to the request specification and supplies the object we&#8217;re submitting. The second follows <code>retrieve()</code> and reads the response body, converting the returned JSON into a <code>Post</code> record.</p><p>This distinction is important because the object submitted to the API doesn&#8217;t always have the same structure as the object returned by the server. The request may contain only the information required to create a resource, while the response can include additional values such as an assigned identifier.</p><p>To submit a post, we can create a <code>CreatePostRequest</code> and pass it to the service method from code that already has an injected <code>PostClient</code> instance:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;36fcfbe9-4888-41d2-9555-47024ad5ee39&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">CreatePostRequest request = new CreatePostRequest(
        1,
        "Calling REST APIs",
        "Submitting a post through Spring Boot RestClient"
);

Post created = postClient.createPost(request);</code></pre></div><p>We supply the user ID, title, and body in the same order they&#8217;re declared in <code>CreatePostRequest</code>. The record is then passed into <code>createPost()</code>, which sends the information to the external endpoint and returns the converted response.</p><p>During serialization, Jackson produces a JSON object containing the <code>userId</code>, <code>title</code>, and <code>body</code> properties. We don&#8217;t need to create a JSON string manually or assign those values individually to the HTTP request content.</p><p>JSONPlaceholder responds to a successful creation request with HTTP status <code>201 Created</code> and a JSON object containing the submitted information along with an assigned ID. Its documented POST behavior returns ID <code>101</code> for the simulated resource, which we can read through the <code>id()</code> method on the returned <code>Post</code> record.</p><p>The service doesn&#8217;t permanently store resources submitted through this endpoint. JSONPlaceholder simulates the creation operation, so requesting the newly assigned post ID afterward doesn&#8217;t retrieve a persisted record. The HTTP request and response behavior still follows the documented endpoint contract.</p><p>For APIs that modify existing resources, <code>RestClient</code> also provides <code>put()</code> and <code>patch()</code>. The general request construction remains similar, but the HTTP method communicates the intended operation. <code>PUT</code> commonly replaces the representation of a resource, while <code>PATCH</code> submits modifications to part of an existing resource according to the patch format supported by the API.</p><p>We should follow the remote API&#8217;s endpoint contract when deciding which HTTP method to call and which fields to include in the request body. The Java object must represent the data the endpoint accepts, and the submitted content type must match the format expected by the server.</p><p>Response bodies aren&#8217;t required for every successful operation, either. Some endpoints return an HTTP status without any content, such as <code>204 No Content</code>, while others return a newly created or updated resource.</p><p>For operations where we don&#8217;t need to deserialize a response body, <code>retrieve().toBodilessEntity()</code> completes the request and returns a <code>ResponseEntity&lt;Void&gt;</code> containing the status and response headers. This allows us to inspect the result without expecting JSON content that the server never supplied.</p><p>When a response body is expected, methods such as <code>body(Post.class)</code> and <code>toEntity(Post.class)</code> remain available, allowing us to select the method that provides the information needed by the caller.</p><h4>Handling HTTP Errors</h4><p>External HTTP requests can fail for reasons related to the request itself, the remote server, or the connection between services. Invalid resource identifiers, rejected credentials, unavailable endpoints, and server failures can all prevent an operation from completing successfully. <code>RestClient</code> provides default exception handling for HTTP error responses, along with methods for customizing how particular statuses are handled.</p><p>HTTP status codes identify how the server processed a request. Responses in the <code>2xx</code> range indicate successful processing, while <code>4xx</code> responses generally identify request-related problems and <code>5xx</code> responses indicate server-side failures. For example, an API may return <code>404 Not Found</code> when a requested resource doesn&#8217;t exist or <code>401 Unauthorized</code> when valid authentication credentials haven&#8217;t been provided. Server-side failures can produce responses such as <code>500 Internal Server Error</code> or <code>503 Service Unavailable</code>.</p><p>When we call <code>retrieve()</code>, <code>RestClient</code> applies default status handling as the response is processed. For HTTP responses with status codes from <code>400</code> through <code>599</code>, it normally throws an exception rather than treating the response as a successful result.</p><p>The exact exception depends on the returned status. Client errors produce <code>HttpClientErrorException</code> or an applicable subclass, while server errors produce <code>HttpServerErrorException</code> or a corresponding subclass. These exceptions belong to the <code>RestClientResponseException</code> hierarchy, which gives us access to information about the failed response. Through <code>getStatusCode()</code>, we can inspect the HTTP status returned by the server. The <code>getResponseBodyAsString()</code> method provides the response content as text, which can help when an API includes diagnostic information or a service-specific error response.</p><p>Spring also supports custom handling for particular status codes through <code>onStatus()</code>. This method accepts a condition that identifies matching responses and a handler that determines what should happen when the condition is met.</p><p>For another example, we may want to distinguish a missing post from other failures by throwing an exception with information about the requested resource.</p><p>We&#8217;ll add a <code>findPost()</code> method to <code>PostClient</code> that handles HTTP <code>404</code> responses:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;afc4dda6-d159-4e84-878e-909f577cafe6&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public Post findPost(int id) {
    return restClient.get()
            .uri("/posts/{id}", id)
            .retrieve()
            .onStatus(
                    status -&gt; status.value() == 404,
                    (request, response) -&gt; {
                        throw new IllegalArgumentException(
                                "Post " + id + " was not found"
                        );
                    }
            )
            .body(Post.class);
}</code></pre></div><p>We begin with the same GET operation we&#8217;ve already established, but the response handling now includes a condition that checks the HTTP status code. When the server returns <code>404</code>, the custom handler throws an <code>IllegalArgumentException</code> identifying the requested post.</p><p>The <code>status.value()</code> call retrieves the numeric HTTP status code so we can compare it with <code>404</code>. The handler also receives access to the outgoing request and incoming response, allowing more detailed error processing when the application needs it.</p><p>If the server returns a successful response, the custom handler doesn&#8217;t run, and <code>body(Post.class)</code> converts the returned JSON into a <code>Post</code> record as usual. Other HTTP error responses that don&#8217;t match the condition continue through the default error handling behavior.</p><p>The exception type selected for custom handling depends on what the surrounding application expects. <code>IllegalArgumentException</code> provides a familiar Java exception for this example, but we can define a custom exception class when callers need to identify a particular API failure.</p><p><code>onStatus()</code> applies to the individual request where it&#8217;s declared. If several methods need identical error handling, <code>RestClient.Builder</code> also supports <code>defaultStatusHandler()</code> for registering a handler that applies across requests made through the resulting client. Request-level handlers take precedence over builder-level default handlers.</p><p>We also need to distinguish HTTP error responses from failures that occur before the client receives a usable HTTP response.</p><p>Network interruptions, connection failures, and read timeouts don&#8217;t necessarily produce HTTP status codes. If the remote server cannot be reached or communication fails during the operation, Spring can report the problem through <code>ResourceAccessException</code>, which represents an I/O failure during client communication.</p><p>These failures are different from a server deliberately responding with an HTTP error. Receiving <code>500 Internal Server Error</code> means an HTTP response reached the client, while a connection timeout means the client couldn&#8217;t complete the affected connection operation within its configured limit.</p><p>When we need different behavior for these conditions, we can catch their exceptions individually.</p><p>We&#8217;ll add the following method to <code>PostClient</code> to distinguish HTTP response failures from connection-related failures:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;57080e23-3832-4d04-af1f-826d5dead219&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public Post getPostWithFailureDetails(int id) {
    try {
        return getPost(id);
    } catch (RestClientResponseException ex) {
        throw new IllegalStateException(
                "Post API returned HTTP " + ex.getStatusCode().value(),
                ex
        );
    } catch (ResourceAccessException ex) {
        throw new IllegalStateException(
                "Unable to communicate with the Post API",
                ex
        );
    }
}</code></pre></div><p>For this method, we&#8217;ll import <code>org.springframework.web.client.RestClientResponseException</code> and <code>org.springframework.web.client.ResourceAccessException</code>.</p><p>We call the existing <code>getPost()</code> method inside a <code>try</code> block, allowing successful responses to return their converted <code>Post</code> records. If the API returns an HTTP error, the first <code>catch</code> block receives the <code>RestClientResponseException</code> and retrieves the status code through <code>getStatusCode()</code>.</p><p>The method then throws an <code>IllegalStateException</code> containing the HTTP status and preserves the original exception as its cause. Retaining that cause allows the underlying response exception to remain available for logging or further inspection by the calling application. If communication fails because of an I/O problem, the second <code>catch</code> block handles <code>ResourceAccessException</code>. We can report that condition independently of HTTP status errors because there may be no completed response from which to obtain a status code.</p><p>Other failures can occur during response processing, including situations where the server returns content that cannot be converted into the requested Java type. Those failures aren&#8217;t automatically classified as HTTP status errors or network access failures, so the surrounding application may need to account for other exception types depending on the operation being performed.</p><p>HTTP error responses may also contain JSON with error codes or descriptions defined by the remote API. When those details are needed, we can inspect the response body through the exception or configure a status handler to process the returned content. The exact structure depends on the API&#8217;s error response contract.</p><p>Retry behavior requires attention as well. <code>RestClient</code> doesn&#8217;t automatically repeat a failed operation solely because the server returned an error or the request exceeded its timeout. Retrying can be appropriate for temporary communication problems, but repeated requests can have consequences for operations that create or modify resources.</p><p>Repeating a <code>POST</code> request after a read timeout could create a duplicate resource if the server completed the original operation before the response was lost. We therefore need to account for the HTTP method and the remote service&#8217;s behavior before adding automatic retries.</p><p>With the default exception hierarchy, custom status handlers, and network exception handling, we can distinguish successful responses from different failures that occur during external API communication. The methods in <code>PostClient</code> can return converted Java records when requests succeed, while callers can respond to HTTP errors and communication failures according to the operation being performed.</p><h3>Conclusion</h3><p>With <code>RestClient</code> in Spring Boot 4, we can make HTTP requests to external APIs, send headers and JSON data, and read the responses as Java objects. Configuring the base URL and timeouts gives us control over how the client communicates with each service, while methods such as <code>retrieve()</code> and <code>body()</code> handle response processing. We can also handle HTTP errors and connection failures, allowing the application to respond appropriately when a request doesn&#8217;t complete as expected.</p><ol><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-framework%2Freference%2Fintegration%2Frest-clients.html">Spring Framework REST Clients Documentation</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-boot%2Freference%2Fio%2Frest-client.html">Spring Boot REST Client Documentation</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-framework%2Fdocs%2Fcurrent%2Fjavadoc-api%2Forg%2Fspringframework%2Fweb%2Fclient%2FRestClient.html">RestClient API Reference</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-boot%2Fappendix%2Fapplication-properties%2Findex.html">Spring Boot Application Properties</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fjsonplaceholder.typicode.com%2F%23guide">JSONPlaceholder API Guide</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2F%3Futm_source%3Dsubstack%26utm_medium%3Demail%26utm_content%3Dshare%26action%3Dshare"><span>Share Alexander Obregon's Substack</span></a></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21APSi%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F4ccdb2fd-411f-44fc-9081-abc7b4b4ad0d_276x276.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!APSi!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4ccdb2fd-411f-44fc-9081-abc7b4b4ad0d_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!APSi!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4ccdb2fd-411f-44fc-9081-abc7b4b4ad0d_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!APSi!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4ccdb2fd-411f-44fc-9081-abc7b4b4ad0d_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!APSi!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4ccdb2fd-411f-44fc-9081-abc7b4b4ad0d_276x276.png 1456w" sizes="auto, 100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21APSi%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F4ccdb2fd-411f-44fc-9081-abc7b4b4ad0d_276x276.png" width="276" height="276" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/4ccdb2fd-411f-44fc-9081-abc7b4b4ad0d_276x276.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:276,&quot;width&quot;:276,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!APSi!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4ccdb2fd-411f-44fc-9081-abc7b4b4ad0d_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!APSi!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4ccdb2fd-411f-44fc-9081-abc7b4b4ad0d_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!APSi!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4ccdb2fd-411f-44fc-9081-abc7b4b4ad0d_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!APSi!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4ccdb2fd-411f-44fc-9081-abc7b4b4ad0d_276x276.png 1456w" sizes="auto, 100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Ficons8.com%2Ficon%2F90519%2Fspring-boot">Spring Boot</a> icon by <a href="iframe.php?url=https%3A%2F%2Ficons8.com%2F">Icons8</a></figcaption></figure></div>]]></content:encoded></item><item><title><![CDATA[Weekly Recap – November 1st, 2026]]></title><description><![CDATA[Spring Boot and SQL]]></description><link>https://alexanderobregon.substack.com/p/weekly-recap-november-1st-2026</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/weekly-recap-november-1st-2026</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Sun, 01 Nov 2026 10:09:25 GMT</pubDate><content:encoded><![CDATA[<p>Last week I posted two new articles covering Spring Boot 4 and SQL. I&#8217;ve been spending some time going through the changes in Spring Boot 4 myself, which is why I&#8217;ve been writing more about it lately. This week&#8217;s Spring Boot article goes over managing transactions, including how to control transaction behavior and handle rollbacks. The second article covers some different ways to sample rows in SQL, from selecting random records to getting a more representative sample of your data.</p><p>If you&#8217;re new or want to know more about what I do, check out my <a href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fabout">About Me page.</a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fsubscribe%3F"><span>Subscribe now</span></a></p><h4><strong>Substack Articles</strong></h4><div class="digest-post-embed" data-attrs="{&quot;nodeId&quot;:&quot;f3bdfa8c-f8b6-49ba-b43d-8b93d5a25e26&quot;,&quot;caption&quot;:&quot;Database operations can involve several steps within the same service method, from creating a new record to updating existing data through multiple repository calls. If something fails partway through, earlier changes may already be committed while later operations never finish, leaving the database in an inconsistent state. Spring Boot 4 ha&#8230;&quot;,&quot;cta&quot;:null,&quot;showBylines&quot;:true,&quot;showDescription&quot;:true,&quot;showImage&quot;:true,&quot;size&quot;:&quot;sm&quot;,&quot;isEditorNode&quot;:true,&quot;title&quot;:&quot;How to Manage Transactions in Spring Boot 4&quot;,&quot;publishedBylines&quot;:[{&quot;id&quot;:185785576,&quot;name&quot;:&quot;Alexander Obregon&quot;,&quot;bio&quot;:&quot;I publish on Medium and use Substack to share recaps, exclusive articles, and a way to support my programming-focused content.&quot;,&quot;photo_url&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8905d669-ef92-4534-9c0c-f486ad372c94_1905x1905.jpeg&quot;,&quot;is_guest&quot;:false,&quot;bestseller_tier&quot;:null}],&quot;post_date&quot;:&quot;2026-10-28T00:27:08.653Z&quot;,&quot;cover_image&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/b6504649-8737-4bd6-9974-e6f7f58b450e_480x480.png&quot;,&quot;cover_image_alt&quot;:null,&quot;canonical_url&quot;:&quot;https://alexanderobregon.substack.com/p/how-to-manage-transactions-in-spring&quot;,&quot;section_name&quot;:&quot;Java and JVM&quot;,&quot;video_upload_id&quot;:null,&quot;id&quot;:222422657,&quot;type&quot;:&quot;newsletter&quot;,&quot;reaction_count&quot;:0,&quot;comment_count&quot;:0,&quot;publication_id&quot;:4719466,&quot;publication_name&quot;:&quot;Alexander Obregon's Substack&quot;,&quot;publication_logo_url&quot;:&quot;&quot;,&quot;belowTheFold&quot;:false,&quot;youtube_url&quot;:null,&quot;show_links&quot;:null,&quot;feed_url&quot;:null}"></div><div class="digest-post-embed" data-attrs="{&quot;nodeId&quot;:&quot;80f61d28-ea8f-4649-b68b-5856d3fcda4b&quot;,&quot;caption&quot;:&quot;Large database tables can contain millions of records, but we don&#8217;t always need to retrieve every row to inspect data, test queries, or perform statistical calculations. Reading the entire table can take more time and processing resources than necessary, which is where SQL sampling techniques come into play. Depending on what we need, we can&#8230;&quot;,&quot;cta&quot;:null,&quot;showBylines&quot;:true,&quot;showDescription&quot;:true,&quot;showImage&quot;:true,&quot;size&quot;:&quot;sm&quot;,&quot;isEditorNode&quot;:true,&quot;title&quot;:&quot;Sampling Rows with SQL Queries&quot;,&quot;publishedBylines&quot;:[{&quot;id&quot;:185785576,&quot;name&quot;:&quot;Alexander Obregon&quot;,&quot;bio&quot;:&quot;I publish on Medium and use Substack to share recaps, exclusive articles, and a way to support my programming-focused content.&quot;,&quot;photo_url&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8905d669-ef92-4534-9c0c-f486ad372c94_1905x1905.jpeg&quot;,&quot;is_guest&quot;:false,&quot;bestseller_tier&quot;:null}],&quot;post_date&quot;:&quot;2026-10-30T16:17:36.750Z&quot;,&quot;cover_image&quot;:&quot;https://substackcdn.com/image/fetch/$s_!4yz7!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4be836e3-d193-46e2-a110-5a0bdf6a8661_306x306.png&quot;,&quot;cover_image_alt&quot;:null,&quot;canonical_url&quot;:&quot;https://alexanderobregon.substack.com/p/sampling-rows-with-sql-queries&quot;,&quot;section_name&quot;:&quot;SQL&quot;,&quot;video_upload_id&quot;:null,&quot;id&quot;:222426545,&quot;type&quot;:&quot;newsletter&quot;,&quot;reaction_count&quot;:0,&quot;comment_count&quot;:0,&quot;publication_id&quot;:4719466,&quot;publication_name&quot;:&quot;Alexander Obregon's Substack&quot;,&quot;publication_logo_url&quot;:&quot;&quot;,&quot;belowTheFold&quot;:false,&quot;youtube_url&quot;:null,&quot;show_links&quot;:null,&quot;feed_url&quot;:null}"></div><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2F%3Futm_source%3Dsubstack%26utm_medium%3Demail%26utm_content%3Dshare%26action%3Dshare"><span>Share Alexander Obregon's Substack</span></a></p>]]></content:encoded></item><item><title><![CDATA[Sampling Rows with SQL Queries]]></title><description><![CDATA[Large database tables can contain millions of records, but we don&#8217;t always need to retrieve every row to inspect data, test queries, or perform statistical calculations.]]></description><link>https://alexanderobregon.substack.com/p/sampling-rows-with-sql-queries</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/sampling-rows-with-sql-queries</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Fri, 30 Oct 2026 16:17:36 GMT</pubDate><enclosure url="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%214yz7%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F4be836e3-d193-46e2-a110-5a0bdf6a8661_306x306.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21SfRI%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F92bc6799-d236-4a72-84ed-d8a823c99bd6_800x373.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!SfRI!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F92bc6799-d236-4a72-84ed-d8a823c99bd6_800x373.png 424w, https://substackcdn.com/image/fetch/$s_!SfRI!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F92bc6799-d236-4a72-84ed-d8a823c99bd6_800x373.png 848w, https://substackcdn.com/image/fetch/$s_!SfRI!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F92bc6799-d236-4a72-84ed-d8a823c99bd6_800x373.png 1272w, https://substackcdn.com/image/fetch/$s_!SfRI!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F92bc6799-d236-4a72-84ed-d8a823c99bd6_800x373.png 1456w" sizes="100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21SfRI%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F92bc6799-d236-4a72-84ed-d8a823c99bd6_800x373.png" width="800" height="373" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/92bc6799-d236-4a72-84ed-d8a823c99bd6_800x373.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:373,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!SfRI!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F92bc6799-d236-4a72-84ed-d8a823c99bd6_800x373.png 424w, https://substackcdn.com/image/fetch/$s_!SfRI!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F92bc6799-d236-4a72-84ed-d8a823c99bd6_800x373.png 848w, https://substackcdn.com/image/fetch/$s_!SfRI!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F92bc6799-d236-4a72-84ed-d8a823c99bd6_800x373.png 1272w, https://substackcdn.com/image/fetch/$s_!SfRI!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F92bc6799-d236-4a72-84ed-d8a823c99bd6_800x373.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Fcommons.wikimedia.org%2Fwiki%2FFile%3ASql_data_base_with_logo.png">Image Source</a></figcaption></figure></div><p>Large database tables can contain millions of records, but we don&#8217;t always need to retrieve every row to inspect data, test queries, or perform statistical calculations. Reading the entire table can take more time and processing resources than necessary, which is where SQL sampling techniques come into play. Depending on what we need, we can retrieve a fixed number of random rows, select an approximate percentage of the table, or sample records from different groups to preserve their proportions. Each method handles row selection differently, and those differences affect the size of the sample, the records included, and how much processing the database needs to perform.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fsubscribe%3F"><span>Subscribe now</span></a></p><h3>Random Sampling Methods</h3><p>Retrieving random records from a database table allows us to inspect different portions of the available data without relying on their existing order or specific identifier values. Rather than selecting the first few hundred rows, we can introduce a random selection process that gives records an opportunity to appear in the result. SQL databases support several techniques for doing this, ranging from sorting rows by randomly generated values to sampling directly from the table&#8217;s storage. The differences become more noticeable as tables grow larger, particularly when we&#8217;re retrieving a small fraction of millions of records and want to limit the amount of processing involved.</p><h4>Selecting a Fixed Number of Random Rows</h4><p>If we already know how large our sample should be, randomly ordering the records before limiting the result is a convenient place to start. We could retrieve 100 orders for inspection or select 500 customer records for database testing without manually choosing individual identifiers. The idea is to assign random sorting values to the eligible records, arrange them according to those values, and return only the requested number of rows.</p><p>PostgreSQL provides the <code>random()</code> function, which returns a pseudo-random floating-point number between zero, inclusive, and one, exclusive. Each evaluation generates a value within that range, allowing us to assign random sorting positions to records instead of ordering them by an existing column.</p><p>We can combine <code>random()</code> with <code>ORDER BY</code> and <code>LIMIT</code> to retrieve 100 records from an <code>orders</code> table:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;208b3713-d580-4ba8-9842-cacac8b96cbc&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT order_id, customer_id, total_amount
FROM orders
ORDER BY random()
LIMIT 100;</code></pre></div><p>We begin by selecting the <code>order_id</code>, <code>customer_id</code>, and <code>total_amount</code> columns from the orders table. The <code>ORDER BY random()</code> expression gives the eligible records randomly generated sorting values, which PostgreSQL then uses to determine their order. Finally, <code>LIMIT 100</code> restricts the result to the first 100 records from that randomized ordering.</p><p>Because the generated values change between executions, running the query repeatedly will normally return different combinations of orders. Every eligible row has an equal probability of being selected under the random ordering process, provided the generated values behave as expected.</p><p>The requested count is also predictable. If our table contains at least 100 records, the query returns exactly 100. If only 60 records exist, PostgreSQL returns those 60 rather than creating additional rows to reach the requested count.</p><p>We can narrow the eligible records with a <code>WHERE</code> clause when the sample needs to come from a particular portion of the table. PostgreSQL applies the filtering criteria to determine which records qualify, and the random ordering operates on those candidates. This allows us to retrieve a fixed-size random sample from records meeting conditions such as a particular order status or date range.</p><p>Although the general technique is available across several database systems, the random function and row-limiting syntax differ. MySQL provides the <code>RAND()</code> function, which generates floating-point values within the same range as PostgreSQL&#8217;s <code>random()</code> function.</p><p>To retrieve 100 randomly selected orders with MySQL, we can write the following query:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;a3dd12cc-eee7-465a-9236-92fdc9a6586f&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT order_id, customer_id, total_amount
FROM orders
ORDER BY RAND()
LIMIT 100;</code></pre></div><p>Reading through the statement, we first identify the columns we want to retrieve. Next, <code>ORDER BY RAND()</code> assigns random sorting values to the eligible rows, while <code>LIMIT 100</code> retains the first 100 records from the resulting order. The process is nearly identical to PostgreSQL, with the function name being the main difference.</p><p>SQL Server handles random ordering differently because its <code>NEWID()</code> function generates uniqueidentifier values rather than floating-point numbers. We can still sort by those generated values to obtain a randomized order.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;8964bad1-fcb3-45fa-b0a8-709ce92fa2a4&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT TOP (100)
    order_id,
    customer_id,
    total_amount
FROM dbo.orders
ORDER BY NEWID();</code></pre></div><p>The <code>NEWID()</code> function generates identifiers that SQL Server can sort, giving each eligible record a randomized position. We then retrieve the first 100 records through <code>TOP (100)</code>, which appears near the beginning of the statement rather than at the end like PostgreSQL&#8217;s and MySQL&#8217;s <code>LIMIT</code> clauses.</p><p>Despite their syntax differences, all three queries follow the same general process. We assign randomized ordering values to eligible records and return a fixed number of rows from the resulting order, making the technique convenient when the desired sample size is already known.</p><p>There is an important performance detail when applying this method to very large tables. Requesting 100 records doesn&#8217;t mean the database can immediately retrieve 100 randomly selected rows without examining the remaining candidates. To produce a random ordering, the database generally needs to examine every qualifying record and evaluate its random sorting expression.</p><p>Depending on the execution plan, the database may perform a full sort or an optimized operation that retains only the highest-priority rows needed for the requested limit. An optimized top-N sort can reduce memory consumption compared with sorting the entire result, but the database still needs to evaluate the candidate records to determine which belong in the sample.</p><p>If the orders table contains several million records, processing those rows can require far more time than returning the small result itself. The cost depends on the table&#8217;s size, available indexes for filtering conditions, memory, and the execution plan selected by the database.</p><p>We should also distinguish random ordering from ordinary row limiting. Running a <code>SELECT</code> statement with <code>LIMIT 100</code> or <code>TOP (100)</code> without a random ordering expression doesn&#8217;t create a random sample. Without an <code>ORDER BY</code> clause, the database doesn&#8217;t guarantee which qualifying records will be returned.</p><p>For smaller tables or occasional inspections, randomly ordering records provides direct control over the number of returned rows. Larger tables can benefit from sampling methods that avoid the random ordering operation entirely, particularly when an approximate sample size is acceptable.</p><h4>Sampling with Random Probability</h4><p>Rather than requesting an exact number of records, we can give each row a specified probability of being included in the result. This changes how the sample is selected because we&#8217;re evaluating individual records against a probability threshold instead of sorting all candidates and retaining a fixed count. The final number of returned rows can vary between executions, which makes this method appropriate when an approximate sample size is acceptable.</p><p>PostgreSQL supports probability-based filtering through the <code>random()</code> function inside a <code>WHERE</code> clause. Every eligible row can be evaluated against a randomly generated value, and the row is retained if that value falls below our chosen threshold.</p><p>To select approximately 2% of the records from an <code>orders</code> table, we can write the following query:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;3fb7dd05-847d-4efa-85bd-d798cc495cc6&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT order_id, customer_id, total_amount
FROM orders
WHERE random() &lt; 0.02;</code></pre></div><p>We start by retrieving the same order columns as before, but instead of sorting the records randomly, we place <code>random()</code> directly inside the <code>WHERE</code> condition. The expression <code>random() &lt; 0.02</code> evaluates to true when the generated value is below <code>0.02</code>, giving each row a 2% probability of appearing in the result.</p><p>Because PostgreSQL evaluates the random condition independently for each eligible record, the number of returned rows depends on how those individual selections turn out.</p><p>For a table containing 100,000 records, we would expect approximately 2,000 rows to be selected on average. That number represents an expected count rather than an exact result, so individual executions can return different amounts of data. We could retrieve 1,970 records during one execution and 2,030 during the next, with both outcomes being consistent with a 2% sampling probability.</p><p>This differs from <code>ORDER BY random() LIMIT 2000</code>, which returns exactly 2,000 records when enough qualifying rows exist. Probability filtering doesn&#8217;t attempt to reach a particular count. Each record receives an independent opportunity to enter the sample, regardless of how many records have already been selected. We can adjust the probability by changing the threshold in the condition. Setting it to <code>0.10</code> gives each row a 10% chance of inclusion, while <code>0.005</code> corresponds to half of one percent. The values can be adjusted according to the approximate fraction of records we want to retrieve.</p><p>Filtering criteria can also be combined with the random condition to narrow the population we&#8217;re sampling. If we&#8217;re interested only in completed orders, we can restrict the eligible records through the <code>status</code> column before applying the sampling probability:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;fcf2bd43-2e23-4c99-8f72-b38b8ff00bf0&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT order_id, customer_id, total_amount
FROM orders
WHERE status = 'completed'
  AND random() &lt; 0.05;</code></pre></div><p>We now have two conditions that must both evaluate to true before an order can appear in the result. The <code>status = 'completed'</code> condition restricts the eligible records to completed orders, while <code>random() &lt; 0.05</code> gives each of those records a 5% probability of selection.</p><p>Orders with other status values won&#8217;t be included, and the expected sample size depends on the number of completed orders rather than the total number of records in the table.</p><p>If the database contains 200,000 orders but only 80,000 have a completed status, our 5% probability would produce approximately 4,000 sampled completed orders on average. This allows us to target a particular portion of the data without calculating its exact size beforehand.</p><p>The database can determine the physical evaluation order of the conditions, but the returned records must satisfy both predicates. If an index helps locate completed orders, PostgreSQL may be able to reduce the number of records examined for the status condition before evaluating the random predicate for the relevant rows.</p><p>MySQL supports a similar technique through its <code>RAND()</code> function, which can be placed inside a <code>WHERE</code> clause to evaluate an independent random condition for eligible rows. The same probability thresholds apply, although the function name differs from PostgreSQL.</p><p>We also need to account for the amount of processing involved. Probability filtering avoids the random sorting operation required by <code>ORDER BY random()</code>, but it doesn&#8217;t automatically eliminate the need to examine the candidate records.</p><p>To give every eligible row an opportunity to be selected, the database generally has to examine those records and evaluate the random condition. That means a query returning only a small percentage of the table can still read a substantial amount of source data, depending on the filtering conditions and execution plan.</p><p>The variability of the result becomes more noticeable when the table is small or the requested probability is very low. If a table contains only 50 records and we apply a 1% probability, the expected sample size is half a row. Records are either included or excluded, so the actual result could contain zero, a single record, or several records.</p><p>This behavior is important when the sampled records will be passed to a process expecting a minimum number of rows. Probability filtering cannot guarantee that enough records will be returned, and very small sampling probabilities can produce empty results.</p><p>When we can accept an approximate number of records, probability filtering provides a way to sample individual rows without sorting the entire candidate population. If the number of returned records needs to be exact, random ordering with a row limit remains the more appropriate choice.</p><h4>Sampling Through Table Storage</h4><p>Some database systems provide built-in sampling operations that retrieve portions of a table without requiring a random sorting expression or an explicit random comparison for every candidate record. These operations can take advantage of how data is stored, allowing the database to retrieve a smaller amount of information from large tables.</p><p>PostgreSQL supports the <code>TABLESAMPLE</code> clause, which appears immediately after a table reference in the <code>FROM</code> clause. We specify a sampling method along with a percentage indicating how much of the source table we want to sample.</p><p>PostgreSQL provides two built-in methods, <code>SYSTEM</code> and <code>BERNOULLI</code>. Both request an approximate percentage of the table, but they differ in how records are selected and how much data needs to be read.</p><p>The <code>SYSTEM</code> method operates at the storage-block level. PostgreSQL stores table records in fixed-size pages, commonly called blocks, which can contain multiple rows. Rather than evaluating an independent selection decision for every record, <code>SYSTEM</code> randomly selects storage blocks and retrieves the rows belonging to those blocks.</p><p>We can request approximately 1% of the orders table with the following query:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;9619f520-d532-4f46-a19a-272a80ae9e53&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT order_id, customer_id, total_amount
FROM orders TABLESAMPLE SYSTEM (1);</code></pre></div><p>We begin with the familiar <code>SELECT</code> statement, but the sampling operation is now attached directly to the table reference. The <code>TABLESAMPLE SYSTEM (1)</code> clause gives each storage block a 1% probability of being selected. When a block is selected, PostgreSQL retrieves the rows belonging to that block, subject to ordinary row visibility rules.</p><p>This differs from evaluating <code>random()</code> for every record because the database can skip blocks that weren&#8217;t selected. With a low sampling percentage, PostgreSQL can avoid reading most of the table&#8217;s blocks and retrieve records from the selected portions.</p><p>The amount of data returned can vary considerably between executions, partly because storage blocks don&#8217;t necessarily contain the same number of records. Some blocks contain more rows than others due to differences in record sizes, updates, deletions, and the physical organization of the table.</p><p>If a selected block contains 120 records, all 120 can enter the sample. Meanwhile, a block containing only 30 records contributes fewer rows, despite having the same probability of selection. As a result, <code>SYSTEM (1)</code> requests a 1% sampling probability for storage blocks rather than guaranteeing that exactly 1% of the table&#8217;s rows will be returned.</p><p>For smaller tables, the variation can become substantial. If the table occupies only a few storage blocks, a low sampling percentage can return no records because none of those blocks were selected.</p><p>The physical placement of records can also influence the resulting sample. Records inserted around the same time or stored near each other can occupy neighboring blocks, so selecting those blocks can retrieve several related records.</p><p>This behavior makes <code>SYSTEM</code> attractive when we want to reduce the amount of data read from a very large table and can accept an approximate sample size. The tradeoff is that records sharing the same block are sampled as a group rather than receiving independent selection decisions.</p><p>PostgreSQL&#8217;s <code>BERNOULLI</code> method provides a different selection process. Instead of sampling entire storage blocks, it evaluates individual rows according to the requested probability.</p><p>To request approximately 1% of the table through row-level sampling, we can write the following query:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;849ccd51-9dbe-49e8-a0ca-b3d703f0852f&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT order_id, customer_id, total_amount
FROM orders TABLESAMPLE BERNOULLI (1);</code></pre></div><p>We keep the same selected columns and table reference, changing the sampling method from <code>SYSTEM</code> to <code>BERNOULLI</code>. The <code>BERNOULLI (1)</code> expression gives each row an independent 1% probability of inclusion, regardless of which storage block contains it.</p><p>This is similar in statistical behavior to evaluating <code>random() &lt; 0.01</code> for every eligible record. Both methods perform independent row-level selection, and neither guarantees an exact sample size.</p><p>Their execution mechanisms differ, however. <code>BERNOULLI</code> is a built-in table sampling method, while <code>random()</code> inside a <code>WHERE</code> clause is an ordinary filtering expression evaluated during query execution. PostgreSQL must scan the table for <code>BERNOULLI</code> sampling because each row needs an independent inclusion decision. As a result, it generally reads more source data than a low-percentage <code>SYSTEM</code> sample.</p><p>The difference becomes apparent when records are physically grouped near each other. With <code>SYSTEM</code>, selecting a storage block brings in the records belonging to that block. With <code>BERNOULLI</code>, every row receives its own selection decision, so neighboring records aren&#8217;t automatically included merely because they occupy the same block.</p><p>Both methods accept percentages between zero and 100. Selecting zero requests no records, while selecting 100 includes all rows from the sampled table before any subsequent filters are applied.</p><p>SQL Server also provides a <code>TABLESAMPLE</code> clause, although its built-in implementation follows a page-based sampling process similar to PostgreSQL&#8217;s <code>SYSTEM</code> method.</p><p>We can request approximately 1% of an orders table in SQL Server through the following statement:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;dff520d8-3b44-4686-8218-d245f9e5540c&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT order_id, customer_id, total_amount
FROM dbo.orders TABLESAMPLE (1 PERCENT);</code></pre></div><p>The <code>TABLESAMPLE (1 PERCENT)</code> clause tells SQL Server to sample approximately 1% of the table. The database randomly selects storage pages and returns the rows located on those pages, so the resulting number of records can differ from the requested percentage.</p><p>SQL Server supports the <code>SYSTEM</code> sampling method for this operation, and the method name is optional because it is the default.</p><p>There is also a difference in how SQL Server accepts the requested sample size. PostgreSQL&#8217;s built-in <code>TABLESAMPLE</code> methods accept percentages, while SQL Server allows us to request an approximate number of rows.</p><p>We can request approximately 500 records with the following query:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;3bd5d7aa-32be-4300-83b7-65bbe3a1728c&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT order_id, customer_id, total_amount
FROM dbo.orders TABLESAMPLE (500 ROWS);</code></pre></div><p>We specify <code>500 ROWS</code> instead of a percentage, telling SQL Server the approximate number of records we want. The database determines a page-sampling rate based on that request, but the final result can contain fewer or more than 500 rows.</p><p>This differs from <code>TOP (500)</code>, which limits the result to a maximum of 500 records. The <code>TABLESAMPLE</code> clause doesn&#8217;t enforce an exact count because the database selects storage pages that can contain different numbers of rows.</p><p>With page-based sampling, the actual sample depends on which pages are selected, how densely records are stored on those pages, and the sampling rate calculated by the database.</p><p>Filtering introduces an additional detail that affects the records we receive. In PostgreSQL, <code>TABLESAMPLE</code> operates on the table source before ordinary <code>WHERE</code> conditions are evaluated.</p><p>If we request a 1% table sample and then filter for completed orders, PostgreSQL first samples the underlying table and then removes sampled records that don&#8217;t have a completed status. The resulting count depends on which completed orders appeared in the initial table sample, so it isn&#8217;t equivalent to directly sampling 1% of all completed orders.</p><p>This distinction becomes noticeable when the records we&#8217;re interested in account for only a small portion of the table. Sampling a small fraction of the entire table can leave very few qualifying rows after filtering, particularly when those records are concentrated in certain storage blocks.</p><p>SQL Server follows the same general principle, with <code>TABLESAMPLE</code> selecting records from the table source before later query filtering.</p><p>The primary difference between storage-level sampling and the earlier random selection methods comes down to how much source data the database needs to examine. Random ordering generally processes every candidate record to assign randomized positions, while row-level probability methods inspect candidates to make individual inclusion decisions.</p><p>Block-based sampling can avoid reading large portions of the table altogether, which can reduce processing time for very large datasets. The exact benefit depends on the table&#8217;s physical storage, the requested sampling percentage, and the database&#8217;s execution plan.</p><h3>Representative Sampling Methods</h3><p>Random selection gives records an opportunity to appear in a sample, but the result won&#8217;t necessarily reflect the distribution of every category in the original table. If certain groups contain far more records than others, smaller groups can contribute very few observations or be missing from the sample entirely. Depending on what we&#8217;re examining, we may need to control how records are selected from different categories or preserve their original proportions. SQL window functions, grouping operations, and repeatable selection rules give us more control over which records are included and how closely the sample reflects the original population.</p><h4>Selecting Equal Samples Across Groups</h4><p>When a table contains records belonging to different categories, selecting the same number of records from each category can make comparisons more manageable. This technique is called stratified sampling, where we divide the available records into groups known as strata before selecting observations from them.</p><p>These groups can be based on columns such as geographic region, customer type, product category, or another characteristic relevant to our analysis. The objective is to control how records are distributed across the groups rather than allowing the relative size of each group to determine its contribution. For example, our <code>orders</code> table could contain 500,000 orders from one region and only 10,000 from a smaller region. If we randomly select 100 records from the entire table, the larger region will have a much greater chance of contributing records because it accounts for more of the population.</p><p>That distribution can be appropriate for examining the overall population, but it becomes less helpful when we&#8217;re interested in comparing records from each region. We may want 25 completed orders from every region so we can inspect their individual characteristics without having the larger regions dominate the result.</p><p>PostgreSQL provides the <code>ROW_NUMBER()</code> window function, which we can combine with <code>PARTITION BY</code> to assign row numbers independently within each group. By ordering the records randomly inside those groups, we can select a fixed number of observations from every region.</p><p>We can retrieve up to 25 randomly selected completed orders per region with the following query:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;a601402b-e791-4d2b-b78a-63f8ebc479ff&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">WITH ranked_orders AS (
    SELECT
        order_id,
        customer_id,
        region,
        total_amount,
        ROW_NUMBER() OVER (
            PARTITION BY region
            ORDER BY random()
        ) AS sample_rank
    FROM orders
    WHERE status = 'completed'
)
SELECT
    order_id,
    customer_id,
    region,
    total_amount
FROM ranked_orders
WHERE sample_rank &lt;= 25
ORDER BY region, sample_rank;</code></pre></div><p>We begin with a common table expression named <code>ranked_orders</code>, which allows us to calculate randomized row numbers before filtering the final result. Inside that expression, the <code>WHERE status = 'completed'</code> condition restricts our eligible records to completed orders.</p><p>Next, <code>ROW_NUMBER()</code> assigns a sequential number to each record, beginning at one within its group. The <code>PARTITION BY region</code> clause tells PostgreSQL to restart the numbering for each region, while <code>ORDER BY random()</code> determines the randomized order of records within that region.</p><p>The resulting <code>sample_rank</code> column represents each order&#8217;s position within its region&#8217;s randomized selection. We then query the common table expression and apply <code>WHERE sample_rank &lt;= 25</code> to retain only the first 25 records from every region. Finally, <code>ORDER BY region, sample_rank</code> organizes the output so we can read the selected records grouped by region.</p><p>Our final result contains up to 25 completed orders from each region. If a region has 500 qualifying orders, we retrieve 25 randomly selected records from that group. Meanwhile, a region containing only 12 completed orders contributes all 12 because there aren&#8217;t enough records to reach the requested count.</p><p>This technique gives smaller categories more representation than they would typically receive through an unrestricted random selection. We can compare records across regions without needing a different query for each location, and changing the <code>sample_rank</code> threshold allows us to adjust the number of observations per group.</p><p>The distribution of the sample won&#8217;t necessarily match the distribution of the original table, though. If a smaller region accounts for only 2% of all orders but contributes the same number of sampled records as a region accounting for 60%, the smaller region becomes overrepresented compared with its share of the population.</p><p>That can be desirable when our purpose is to inspect or compare individual groups. For statistical calculations intended to represent the entire population, however, equal-size stratified samples may require weighting so that each group&#8217;s contribution reflects its actual population size.</p><p>We should also account for the processing involved. PostgreSQL needs to identify the qualifying rows, generate randomized ordering values within each region, and assign row numbers before the outer query can retrieve the requested records. The window function can require substantial sorting and memory when applied to millions of rows, so limiting the final result doesn&#8217;t necessarily reduce the amount of source data examined.</p><h4>Preserving Group Proportions</h4><p>Matching the original distribution of records becomes important when we want our sample to reflect the relative sizes of different categories. Instead of selecting the same number of records from every group, we can calculate how much each group contributes to the population and select records according to those proportions.</p><p>This is also a form of stratified sampling, but the objective differs from selecting equal sample sizes. We want larger groups to contribute more records and smaller groups to contribute fewer, keeping their relative representation close to what exists in the full table. Such as, our orders table could contain records from three regions. The first region accounts for 50% of all orders, the second accounts for 30%, and the third accounts for the remaining 20%.</p><p>If we want a sample of 1,000 orders that follows those exact proportions, we would select 500 records from the first region, 300 from the second, and 200 from the third. This gives us a sample with the same regional distribution as the original population, provided each group contains enough records. We can accomplish something similar without manually calculating the number of records needed from every region. PostgreSQL allows us to combine <code>ROW_NUMBER()</code> with <code>COUNT()</code> as window functions, giving each record both a randomized position and the total number of records in its group.</p><p>The following query retrieves approximately 5% of the completed orders from every region:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;6fe26c8b-7b13-4f79-abac-1b29919aacad&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">WITH ranked_orders AS (
    SELECT
        order_id,
        region,
        total_amount,
        ROW_NUMBER() OVER (
            PARTITION BY region
            ORDER BY random()
        ) AS sample_rank,
        COUNT(*) OVER (
            PARTITION BY region
        ) AS region_size
    FROM orders
    WHERE status = 'completed'
)
SELECT
    order_id,
    region,
    total_amount
FROM ranked_orders
WHERE sample_rank &lt;= CEIL(region_size * 0.05)
ORDER BY region, sample_rank;</code></pre></div><p>We start inside the <code>ranked_orders</code> common table expression, where the <code>WHERE</code> clause limits our records to completed orders. This means the group sizes and sampling percentages are calculated from completed orders rather than every record in the table.</p><p>The first window function, <code>ROW_NUMBER()</code>, generates randomized positions within each region. We accomplish this through <code>PARTITION BY region</code> and <code>ORDER BY random()</code>, just as we did when selecting equal samples across groups.</p><p>The second window function, <code>COUNT(*) OVER (PARTITION BY region)</code>, calculates the number of qualifying records within each region. Instead of reducing the result to a single row per group, the window function attaches that count to every record belonging to the group.</p><p>This distinction is important because a regular <code>GROUP BY</code> calculation would combine the records into summary rows. We still need the individual orders available so we can select records after calculating the appropriate sample size.</p><p>After calculating those values, we move to the outer query and apply <code>WHERE sample_rank &lt;= CEIL(region_size * 0.05)</code>. Multiplying <code>region_size</code> by <code>0.05</code> calculates 5% of the records in the region, and <code>CEIL()</code> rounds that number upward to the nearest whole number.</p><p>If a region contains 10,000 completed orders, the calculation produces 500, so we retrieve the first 500 records from that region&#8217;s randomized ordering. For a region with 2,000 completed orders, we retrieve 100 records.</p><p>Every region contributes approximately 5% of its qualifying records, allowing the combined sample to retain the general distribution of the source population. We can adjust the percentage by changing <code>0.05</code> to a different fraction without calculating individual group sizes beforehand.</p><p>Rounding introduces some variation, particularly for smaller groups. If a region contains only 17 completed orders, multiplying that count by <code>0.05</code> produces <code>0.85</code>. The <code>CEIL()</code> function rounds the value upward to one, meaning that region contributes one record rather than a fractional observation.</p><p>For very small groups, this can produce a sampling percentage greater than the requested 5%. The combined result may also contain slightly more than 5% of the eligible population because every group rounds its requested count upward independently.</p><p>The distinction between this method and equal-size sampling comes down to how we determine the number of records selected from each category. Equal-size sampling assigns the same maximum count to every group, while proportional sampling calculates that count from the number of records belonging to the group.</p><p>There are also statistical considerations when deciding how much representation each category should receive. Keeping the original group proportions can support estimates intended to reflect the overall population, but matching the distribution of a particular column doesn&#8217;t guarantee that other characteristics will have the same distribution as the original data.</p><p>Preserving regional proportions, for instance, doesn&#8217;t automatically preserve the distribution of order amounts, purchase dates, or customer activity within those regions. Random selection can still produce differences, particularly when rare values account for a very small percentage of the population.</p><p>We should also account for how PostgreSQL processes the query. The database needs to identify the qualifying records, calculate group counts, and assign randomized row numbers within each region before applying the final sample-size condition.</p><p>On large tables, these operations can involve considerable sorting and processing. If we only need approximate group proportions rather than calculated counts from every region, applying the same independent row-level sampling probability to all eligible records can require less processing, although the proportions in any individual sample will vary.</p><h4>Creating Repeatable Samples</h4><p>For database testing and comparisons, retrieving different random records during every execution can make it difficult to determine why a result changed. If we&#8217;re testing a calculation against 500 sampled orders, changes in the selected records can affect the output even when the calculation itself hasn&#8217;t changed.</p><p>Repeatable sampling gives us a way to retrieve the same selection across multiple executions, provided the conditions affecting the selection remain unchanged. This lets us compare query behavior against consistent input records while retaining the benefits of sampling a smaller portion of the table.</p><p>PostgreSQL supports a <code>REPEATABLE</code> clause with its built-in <code>TABLESAMPLE</code> methods. The clause accepts a numeric seed that influences the pseudo-random selection process.</p><p>We can request a repeatable 5% row-level sample with the following query:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;7a6c9643-7e1d-4244-ac1d-61051b533422&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT order_id, customer_id, total_amount
FROM orders
TABLESAMPLE BERNOULLI (5) REPEATABLE (73);</code></pre></div><p>We begin by retrieving our order columns and requesting a 5% sample through <code>TABLESAMPLE BERNOULLI (5)</code>. The <code>REPEATABLE (73)</code> clause supplies a fixed seed to the sampling method, allowing PostgreSQL to reproduce the selection when the same query is executed again under unchanged table conditions.</p><p>If we execute the query repeatedly with the same percentage and seed, PostgreSQL returns the same subset, provided the underlying table hasn&#8217;t changed. Changing the seed generally produces a different selection, which gives us a way to create alternative samples without changing the requested percentage.</p><p>The seed itself doesn&#8217;t represent a percentage, row count, or identifier. It&#8217;s a numeric value supplied to the sampling process so the same sequence of random decisions can be reproduced.</p><p>There are limits to that consistency. If records are inserted, updated, or deleted, the sampled result can change because the table no longer has the same contents or storage state. Changes to the table&#8217;s physical organization can also affect repeatability.</p><p>For this reason, seeded sampling is helpful when we&#8217;re testing against an unchanged dataset, but it shouldn&#8217;t be treated as permanent storage for a particular collection of records.</p><p>We can also create repeatable selections through deterministic hashing, which calculates the same output whenever it receives the same input.</p><p>PostgreSQL provides the <code>md5()</code> function, which calculates a hexadecimal representation of an MD5 hash from a text value. We can calculate hashes from unique order identifiers and sort records according to those results.</p><p>To retrieve 500 records in a repeatable, pseudo-random-looking order, we can write the following query:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;7767faf5-6163-4597-9aaa-3a47911fe190&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT order_id, customer_id, total_amount
FROM orders
ORDER BY
    md5(order_id::text || '-sample-73'),
    order_id
LIMIT 500;</code></pre></div><p>We start with the <code>order_id</code> column, converting its value to text through <code>order_id::text</code>. The <code>||</code> operator concatenates that identifier with the fixed text <code>-sample-73</code>, creating an input value for the <code>md5()</code> function.</p><p>Each order receives a hash calculated from its unique identifier and the fixed sample label. Because the hash calculation is deterministic, the same identifier and label produce the same value every time the query runs.</p><p>We then sort the records according to those calculated hashes and retrieve the first 500 through <code>LIMIT 500</code>. The <code>order_id</code> column provides a consistent secondary sorting order in the unlikely event that two records produce identical hash values.</p><p>Changing the sample label changes the calculated hashes and generally produces a different ordering. Keeping the label unchanged allows us to reproduce the same selection from an unchanged collection of unique identifiers.</p><p>This method is convenient for test data selection, where we want a consistent collection of records without storing a manually selected list of identifiers. The hashed ordering provides a pseudo-random-looking distribution, although it isn&#8217;t a substitute for a sampling method with established statistical guarantees or for cryptographically secure randomness.</p><p>There is also a difference between keeping individual record positions consistent and keeping the entire selected collection unchanged.</p><p>If an existing order retains its identifier, its hash value won&#8217;t change. However, inserting a new record can introduce a hash that sorts ahead of an existing selected record, potentially changing which orders appear among the first 500.</p><p>Deleting an order can similarly change the selected collection because a record that previously fell outside the first 500 positions can move into the result. Changing an identifier also changes the value passed to <code>md5()</code>, which can alter that record&#8217;s position.</p><p>For testing that must always involve exactly the same source records, storing the selected identifiers in a dedicated table provides greater consistency. We can retrieve records by those saved identifiers during later executions, provided the records still exist and remain accessible.</p><p>If the values in those records can change, storing identifiers alone won&#8217;t preserve the original data. In that situation, retaining a snapshot of the selected records is more appropriate for tests that require identical input values across executions.</p><h4>Checking Sample Quality</h4><p>After retrieving a sample, we need to examine how closely its characteristics match the population we&#8217;re interested in. Random selection doesn&#8217;t guarantee that every category will receive adequate representation, and a sample containing the requested number of records can still differ from the source table in important ways.</p><p>The appropriate checks depend on what we&#8217;re trying to accomplish. For inspecting individual records, we may care primarily about retrieving a varied collection of observations. Statistical calculations require more attention to how the selected records reflect the population and how much uncertainty comes from sampling.</p><p>We can begin by comparing the distribution of categories in our sample against the distribution in the original table. This helps us identify groups that are represented differently from their population percentages.</p><p>To examine a consistent collection of records across multiple queries, we can create a temporary table containing 500 randomly selected orders in PostgreSQL:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;54d77c31-df1b-4246-b373-0682b2c3fcc3&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">CREATE TEMP TABLE sampled_orders AS
SELECT
    order_id,
    customer_id,
    region,
    total_amount
FROM orders
ORDER BY random()
LIMIT 500;</code></pre></div><p>We begin with <code>CREATE TEMP TABLE sampled_orders AS</code>, which creates a temporary table from the result of the following <code>SELECT</code> statement. The query retrieves the requested columns from <code>orders</code>, randomly orders the candidate records, and retains a maximum of 500 rows.</p><p>Because the selected records are copied into <code>sampled_orders</code>, subsequent queries against that temporary table examine the same stored collection rather than generating a new random selection every time.</p><p>PostgreSQL temporary tables normally remain available for the duration of the database session and are automatically removed when that session ends. This makes them convenient for examining sampled data without adding a permanent table to the database.</p><p>With our sample stored, we can calculate how the selected records are distributed across regions:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;54247be6-abba-43e8-bfe8-4998aff4c549&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    region,
    COUNT(*) AS sampled_rows
FROM sampled_orders
GROUP BY region
ORDER BY region;</code></pre></div><p>Here, we retrieve the <code>region</code> column and apply <code>COUNT(*)</code> to calculate the number of sampled records belonging to each region. The <code>GROUP BY region</code> clause groups records according to their regional values, while <code>ORDER BY region</code> arranges the results by region so we can compare them.</p><p>The output contains a row for every region represented in the sample, along with its sampled record count. Regions that weren&#8217;t selected at all won&#8217;t appear in this result, which is important when examining categories with relatively small populations.</p><p>Next, we can calculate the corresponding counts from the original table:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;9c116891-29d9-4c55-b52b-7637f53b3251&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    region,
    COUNT(*) AS total_rows
FROM orders
GROUP BY region
ORDER BY region;</code></pre></div><p>This query follows the same grouping process, but we retrieve the counts from <code>orders</code> rather than <code>sampled_orders</code>. The result tells us how frequently each region occurs in the original population.</p><p>We can now compare the two results to see how the sampled records are distributed relative to the source data.</p><p>Because the datasets have different sizes, comparing raw counts alone won&#8217;t tell us very much. If a region has 25,000 records in the original table and 125 in our sample, we need to calculate what percentage of each dataset those counts represent. For a population containing 100,000 orders, those 25,000 records account for 25% of the total. If our sample contains 500 orders, the 125 sampled records also represent 25%, meaning that region has the same proportion in both datasets.</p><p>We would generally expect an unrestricted random sample to produce percentages near the population values, although the exact percentages can differ from one selection to the next.</p><p>The amount of variation depends partly on the sample size. Smaller samples tend to have greater percentage fluctuations, while larger random samples generally produce more consistent estimates of common population characteristics.</p><p>Rare categories deserve attention because they have a greater chance of being absent from smaller samples. If a particular region accounts for only a tiny fraction of the original table, we could retrieve hundreds of randomly selected records without including any from that region.</p><p>This doesn&#8217;t necessarily mean the random selection process is defective. It can be an ordinary consequence of sampling a small portion of a population containing uncommon observations.</p><p>For statistical analysis, we may also need to examine numerical columns rather than only counting categorical values. In our orders table, <code>total_amount</code> provides an opportunity to compare the distribution of monetary amounts between the sampled records and the original population.</p><p>We can calculate summary statistics from the sampled orders with PostgreSQL aggregate functions:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;ef56c0cd-01e3-49a0-8224-e832f310d463&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    COUNT(*) AS sampled_rows,
    ROUND(AVG(total_amount), 2) AS average_amount,
    MIN(total_amount) AS minimum_amount,
    MAX(total_amount) AS maximum_amount
FROM sampled_orders;</code></pre></div><p>In this, we begin with <code>COUNT(*)</code> to retrieve the number of sampled rows, followed by <code>AVG(total_amount)</code> to calculate the average order amount. The <code>ROUND()</code> function rounds that average to two decimal places, assuming <code>total_amount</code> is stored as a numeric or decimal value.</p><p>The <code>MIN()</code> and <code>MAX()</code> functions retrieve the smallest and largest non-null order amounts in the sample. By running the equivalent calculations against the original <code>orders</code> table, we can examine how the sampled values compare with the full population.</p><p>Average values can be relatively close while the minimum and maximum values differ considerably. If unusually expensive orders are rare, a small random sample can miss them entirely, causing the sample&#8217;s maximum value to fall well below the population maximum.</p><p>The same concern applies to distributions beyond their average and extreme values. Two datasets can have similar averages while containing different concentrations of small, medium, and large transactions.</p><p>For more detailed statistical analysis, we can examine percentiles, frequency distributions, and other measurements relevant to the selected columns. These comparisons help us identify characteristics that may not be adequately represented by the current sample.</p><p>The method that produced the sample can also affect its statistical properties. Page-based sampling can retrieve groups of physically nearby records, which can influence results if neighboring records share similar characteristics.</p><p>Selecting records from a limited date range introduces a different restriction because records outside that period have no opportunity to enter the sample. If we&#8217;re trying to represent the entire history of the table, that restriction changes the population available for selection. We should distinguish these sampling decisions from ordinary random variation. Differences caused by random selection can occur even under an unbiased sampling process, while differences introduced by excluding parts of the population or favoring certain groups can produce systematic bias.</p><p>The database processing required to retrieve a sample is worth examining as well. Queries that randomly sort millions of records can consume considerable processing time and memory, even if we only retrieve a few hundred rows.</p><p>PostgreSQL provides <code>EXPLAIN (ANALYZE, BUFFERS)</code> to inspect how a query executes and how database buffers are accessed during processing:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;8d6e816d-ff32-4ea3-ad25-ea8e56e2c73b&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">EXPLAIN (ANALYZE, BUFFERS)
SELECT order_id, customer_id, total_amount
FROM orders
ORDER BY random()
LIMIT 500;</code></pre></div><p>We place <code>EXPLAIN (ANALYZE, BUFFERS)</code> before the query we want to examine. The <code>ANALYZE</code> option tells PostgreSQL to execute the statement and report actual execution measurements, including the number of rows processed and the time spent performing operations.</p><p>The <code>BUFFERS</code> option adds information about buffer activity, including blocks accessed from the database buffer cache and blocks that needed to be read. These measurements provide more information about how much database activity occurred while executing the statement.</p><p>For our random-ordering query, we can inspect the scan and sorting operations to see how PostgreSQL retrieves the candidate records and selects the final 500 rows.</p><p>We can then compare those measurements with a query that retrieves records through a table sampling method. The execution plans can differ substantially because random ordering generally evaluates the candidate records, while block-based sampling can avoid reading large portions of the table.</p><p>Performance comparisons should be made against tables and data volumes similar to those being examined. Small test tables can produce execution plans that differ from those selected for larger datasets, and available memory, caching, and table organization can influence execution time.</p><p>Taking a look at both the distribution of selected records and the database operations required to retrieve them, we can evaluate the sample according to its intended purpose. The appropriate selection method depends on the required record count, acceptable processing cost, and the statistical characteristics that need to be represented.</p><h3>Conclusion</h3><p>Sampling rows with SQL gives us several ways to retrieve smaller portions of large tables without processing every record in the final result. Random ordering lets us select a fixed number of rows, while probability filtering and <code>TABLESAMPLE</code> provide options for retrieving approximate subsets. When we need more control over the distribution of records, window functions allow us to select equal or proportional samples from different groups. We can also reproduce selections through fixed seeds or deterministic hashing, then compare the sampled data against the original population. Choosing the right method comes down to how records are selected, the sample size we need, and the amount of processing required to retrieve them.</p><ol><li><p><em><a href="iframe.php?url=https%3A%2F%2Fwww.postgresql.org%2Fdocs%2Fcurrent%2Fsql-select.html">PostgreSQL SELECT Docs</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fwww.postgresql.org%2Fdocs%2Fcurrent%2Ffunctions-window.html">PostgreSQL Window Functions</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fwww.postgresql.org%2Fdocs%2Fcurrent%2Ffunctions-math.html">PostgreSQL Mathematical Functions</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Flearn.microsoft.com%2Fen-us%2Fsql%2Ft-sql%2Fqueries%2Ffrom-transact-sql%3Fview%3Dsql-server-ver17">SQL Server FROM Clause Docs</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdev.mysql.com%2Fdoc%2Frefman%2F8.4%2Fen%2Fmathematical-functions.html">MySQL Mathematical Functions</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fwww.postgresql.org%2Fdocs%2Fcurrent%2Fsql-explain.html">PostgreSQL EXPLAIN Docs</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2F%3Futm_source%3Dsubstack%26utm_medium%3Demail%26utm_content%3Dshare%26action%3Dshare"><span>Share Alexander Obregon's Substack</span></a></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%214yz7%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F4be836e3-d193-46e2-a110-5a0bdf6a8661_306x306.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!4yz7!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4be836e3-d193-46e2-a110-5a0bdf6a8661_306x306.png 424w, https://substackcdn.com/image/fetch/$s_!4yz7!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4be836e3-d193-46e2-a110-5a0bdf6a8661_306x306.png 848w, https://substackcdn.com/image/fetch/$s_!4yz7!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4be836e3-d193-46e2-a110-5a0bdf6a8661_306x306.png 1272w, https://substackcdn.com/image/fetch/$s_!4yz7!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4be836e3-d193-46e2-a110-5a0bdf6a8661_306x306.png 1456w" sizes="auto, 100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%214yz7%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F4be836e3-d193-46e2-a110-5a0bdf6a8661_306x306.png" width="306" height="306" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/4be836e3-d193-46e2-a110-5a0bdf6a8661_306x306.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:306,&quot;width&quot;:306,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!4yz7!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4be836e3-d193-46e2-a110-5a0bdf6a8661_306x306.png 424w, https://substackcdn.com/image/fetch/$s_!4yz7!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4be836e3-d193-46e2-a110-5a0bdf6a8661_306x306.png 848w, https://substackcdn.com/image/fetch/$s_!4yz7!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4be836e3-d193-46e2-a110-5a0bdf6a8661_306x306.png 1272w, https://substackcdn.com/image/fetch/$s_!4yz7!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4be836e3-d193-46e2-a110-5a0bdf6a8661_306x306.png 1456w" sizes="auto, 100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Ffreesvg.org%2Fmono-sql">Image Source</a></figcaption></figure></div>]]></content:encoded></item><item><title><![CDATA[How to Manage Transactions in Spring Boot 4]]></title><description><![CDATA[Database operations can involve several steps within the same service method, from creating a new record to updating existing data through multiple repository calls.]]></description><link>https://alexanderobregon.substack.com/p/how-to-manage-transactions-in-spring</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/how-to-manage-transactions-in-spring</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Wed, 28 Oct 2026 00:27:08 GMT</pubDate><enclosure url="iframe.php?url=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb6504649-8737-4bd6-9974-e6f7f58b450e_480x480.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%2147Yt%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F376dc295-250e-43cc-8667-6123eb901707_800x444.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!47Yt!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F376dc295-250e-43cc-8667-6123eb901707_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!47Yt!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F376dc295-250e-43cc-8667-6123eb901707_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!47Yt!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F376dc295-250e-43cc-8667-6123eb901707_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!47Yt!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F376dc295-250e-43cc-8667-6123eb901707_800x444.jpeg 1456w" sizes="100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%2147Yt%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F376dc295-250e-43cc-8667-6123eb901707_800x444.jpeg" width="800" height="444" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/376dc295-250e-43cc-8667-6123eb901707_800x444.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:444,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!47Yt!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F376dc295-250e-43cc-8667-6123eb901707_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!47Yt!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F376dc295-250e-43cc-8667-6123eb901707_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!47Yt!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F376dc295-250e-43cc-8667-6123eb901707_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!47Yt!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F376dc295-250e-43cc-8667-6123eb901707_800x444.jpeg 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Fspring.io%2Fprojects%2Fspring-boot">Image Source</a></figcaption></figure></div><p>Database operations can involve several steps within the same service method, from creating a new record to updating existing data through multiple repository calls. If something fails partway through, earlier changes may already be committed while later operations never finish, leaving the database in an inconsistent state. Spring Boot 4 handles these situations through Spring Framework 7&#8217;s transaction management, allowing us to group related database operations within a single transaction through <code>@Transactional</code>. This gives us control over when changes are committed, how exceptions trigger rollbacks, and how read-only operations are handled. Knowing where transactions begin and end can also help explain why certain methods don&#8217;t always behave as expected, particularly when calls between service methods bypass Spring&#8217;s transaction proxy.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fsubscribe%3F"><span>Subscribe now</span></a></p><h3>Establishing the Transaction Boundary</h3><p>Transactions become important when several database operations contribute to a single action, like creating an order while updating the inventory associated with it. If those operations execute independently, a failure halfway through can leave changes committed that should have been canceled. Spring Boot 4 gives us transaction management through Spring Framework 7, allowing related database operations to share the same transaction boundary. We&#8217;ll begin with repository calls that aren&#8217;t enclosed by a service-level transaction, then move that responsibility into the service so the database can commit or roll back the entire purchase.</p><h4>Starting With Multiple Repository Calls</h4><p>Placing an order usually involves more than inserting a record into a database. We also need to locate the purchased product, check its available quantity, and update inventory to reflect the purchase. These operations depend on each other because saving an order without reserving the inventory could leave the customer with a purchase that cannot be fulfilled.</p><p>For this example, we&#8217;ll use Spring Boot 4 with Spring Data JPA and PostgreSQL. Spring Data JPA provides repository interfaces for accessing database entities, while Hibernate handles persistence operations through JPA.</p><p>The required dependencies can be added to a Gradle project through <code>spring-boot-starter-data-jpa</code> and the PostgreSQL JDBC driver.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;064e886c-b892-43c3-92bc-cb0ee6007262&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import jakarta.persistence.*;

@Entity
@Table(name = "inventory_items")
public class InventoryItem {

    @Id
    private Long id;

    @Version
    private Long version;

    @Column(nullable = false)
    private int availableQuantity;

    protected InventoryItem() {
    }

    public int getAvailableQuantity() {
        return availableQuantity;
    }

    public void reserve(int quantity) {
        if (quantity &lt;= 0) {
            throw new IllegalArgumentException(
                "Quantity must be positive"
            );
        }

        if (availableQuantity &lt; quantity) {
            throw new IllegalStateException(
                "Insufficient inventory"
            );
        }

        availableQuantity -= quantity;
    }
}</code></pre></div><p>We&#8217;ve defined the entity with an identifier, an available quantity, and a version number managed by JPA. The <code>id</code> field identifies an existing inventory record, allowing the application to retrieve the corresponding product from the database. Its protected constructor gives JPA a way to instantiate the entity when loading records without requiring callers to create inventory objects through that constructor. The <code>reserve()</code> method handles the inventory adjustment. Before reducing the available quantity, we check that the requested amount is positive and doesn&#8217;t exceed the stock currently recorded. If either condition fails, an exception interrupts the operation before the quantity changes.</p><p>Keeping this validation within the entity prevents callers from reducing inventory below zero through <code>reserve()</code>. It also provides the ordering service with a way to detect purchases that cannot be completed, without requiring the service to repeat the inventory validation itself.</p><p>We&#8217;ve also included the <code>@Version</code> annotation, which introduces optimistic locking. Hibernate manages the version value and checks it when updating the entity. If a competing transaction has already committed a modification to the same record, the version comparison can detect that conflict rather than allowing an older inventory value to overwrite the newer one. This becomes important when multiple customers attempt to purchase the same product around the same time.</p><p>Next, we need a <code>PurchaseOrder</code> entity to represent an order submitted by a customer:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;db7cb73a-b132-4bb7-9b41-51cc80636417&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import jakarta.persistence.*;

@Entity
@Table(name = "purchase_orders")
public class PurchaseOrder {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private Long itemId;

    @Column(nullable = false)
    private int quantity;

    protected PurchaseOrder() {
    }

    public PurchaseOrder(Long itemId, int quantity) {
        this.itemId = itemId;
        this.quantity = quantity;
    }

    public Long getId() {
        return id;
    }
}</code></pre></div><p>Each purchase order records the inventory item being purchased and the quantity requested. Through the constructor, we can create an order from those values, while <code>@GeneratedValue(strategy = GenerationType.IDENTITY)</code> tells Hibernate that the database generates the identifier during insertion.</p><p>We now have two entities representing different information about the purchase. <code>PurchaseOrder</code> records what the customer ordered, while <code>InventoryItem</code> tracks the quantity remaining in stock. Completing the purchase requires changes to both records, and a failure affecting either operation should prevent the entire purchase from being committed.</p><p>To access these entities, we&#8217;ll create two Spring Data JPA repositories by extending <code>JpaRepository</code>. Each interface belongs in its own Java file.</p><p>We&#8217;ll begin with the inventory repository:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;50f5e871-471c-4873-a9d7-d4373f9e003c&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import org.springframework.data.jpa.repository.JpaRepository;

public interface InventoryRepository
        extends JpaRepository&lt;InventoryItem, Long&gt; {
}</code></pre></div><p>By extending <code>JpaRepository&lt;InventoryItem, Long&gt;</code>, the interface inherits methods for retrieving, saving, deleting, and querying inventory entities. The first generic argument identifies the entity managed by the repository, while <code>Long</code> represents the type of its identifier.</p><p>The purchase order repository follows the same structure but operates on <code>PurchaseOrder</code> entities:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;40898dfe-4ebe-4205-8d53-5ef43d6b575c&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import org.springframework.data.jpa.repository.JpaRepository;

public interface PurchaseOrderRepository
        extends JpaRepository&lt;PurchaseOrder, Long&gt; {
}</code></pre></div><p>Spring Data JPA supplies implementations for inherited methods such as <code>save()</code> and <code>findById()</code>, allowing us to access both tables without writing SQL statements for these operations. With the repositories defined, we can concentrate on coordinating the purchase rather than implementing database access methods individually.</p><p>Initially, the ordering operation could be written as a sequence of repository calls without placing a transaction around the entire service method:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;f9ae9e2b-0aa8-4c91-8936-06d552548d02&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">PurchaseOrder order = orderRepository.save(
    new PurchaseOrder(itemId, quantity)
);

InventoryItem item = inventoryRepository.findById(itemId)
    .orElseThrow(() -&gt;
        new IllegalArgumentException("Item not found")
    );

item.reserve(quantity);

inventoryRepository.save(item);</code></pre></div><p>We begin by creating a <code>PurchaseOrder</code> and passing it to <code>orderRepository.save()</code>. After saving the order, we call <code>inventoryRepository.findById()</code> to retrieve the inventory record associated with the requested item. If the record doesn&#8217;t exist, <code>orElseThrow()</code> raises an <code>IllegalArgumentException</code> instead of allowing the purchase to continue.</p><p>Once the inventory item has been retrieved, <code>reserve()</code> checks the requested quantity and reduces the available stock. We then pass the modified entity to <code>inventoryRepository.save()</code> so the updated quantity can be persisted.</p><p>The sequence follows the expected purchasing process, but executing these statements inside an ordinary service method doesn&#8217;t automatically mean the database treats them as one purchase.</p><p>Spring Data JPA already provides transactional behavior for inherited repository methods. When we call <code>save()</code> without an existing transaction, Spring Data JPA can begin a transaction for that repository operation and commit it after the method finishes successfully.</p><p>This starts to be a problem if the order is saved before the inventory check fails.</p><p>Imagine a customer requests an inventory item that no longer exists. Our initial <code>orderRepository.save()</code> call may have already committed the purchase order, but the following <code>findById()</code> call throws an <code>IllegalArgumentException</code>. The earlier repository transaction has already finished, so the exception cannot undo the committed order.</p><p>Insufficient stock creates a similar situation. If the item exists but its available quantity is lower than the amount requested, <code>reserve()</code> throws an <code>IllegalStateException</code>. The inventory remains unchanged, but the purchase order could already be stored in the database.</p><p>We could also encounter a failure during the final inventory update because of a database constraint, connection problem, or conflicting modification. Without a transaction covering the complete purchase, successful earlier commits remain unaffected by that later failure.</p><p>The underlying issue comes from where the transaction boundary is established. Each inherited repository method has its own transaction configuration, but the service hasn&#8217;t told Spring that the order insertion and inventory update belong to the same database operation.</p><p>What we need is a transaction that remains active throughout the purchase, allowing both changes to commit successfully or be rolled back if the operation fails. The service is the appropriate location for that boundary because it coordinates the repository calls involved in completing the order.</p><h4>Adding <code>@Transactional</code> to the Service</h4><p>Spring provides the <code>@Transactional</code> annotation to declare transaction behavior on a method or class. Rather than manually beginning a transaction, performing database operations, and committing the result, we can annotate the service method responsible for the complete purchase.</p><p>The annotation tells Spring&#8217;s transaction infrastructure to establish a transaction boundary when the method is invoked through Spring&#8217;s transactional proxy.</p><p>For our ordering operation, we want the transaction to begin before the order is inserted and remain active until the inventory adjustment has finished. If something interrupts the purchase during that period, the database changes should be rolled back according to the transaction&#8217;s rollback rules.</p><p>We&#8217;ll move the repository calls into an <code>OrderService</code> class and place <code>@Transactional</code> directly above <code>placeOrder()</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;bddb0c6d-b423-4e80-aaf1-d2166775a2ab&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    private final InventoryRepository inventoryRepository;
    private final PurchaseOrderRepository orderRepository;

    public OrderService(
            InventoryRepository inventoryRepository,
            PurchaseOrderRepository orderRepository) {

        this.inventoryRepository = inventoryRepository;
        this.orderRepository = orderRepository;
    }

    @Transactional
    public Long placeOrder(Long itemId, int quantity) {
        if (quantity &lt;= 0) {
            throw new IllegalArgumentException(
                "Quantity must be positive"
            );
        }

        PurchaseOrder order = orderRepository.save(
            new PurchaseOrder(itemId, quantity)
        );

        InventoryItem item = inventoryRepository.findById(itemId)
            .orElseThrow(() -&gt;
                new IllegalArgumentException("Item not found")
            );

        item.reserve(quantity);

        inventoryRepository.save(item);

        return order.getId();
    }
}</code></pre></div><p>We&#8217;ve marked <code>OrderService</code> with <code>@Service</code>, allowing Spring to register it as a managed component and inject both repositories through its constructor. The <code>@Transactional</code> annotation then establishes a transaction boundary around <code>placeOrder()</code> when the method is invoked through Spring&#8217;s transaction infrastructure.</p><p>Notice that the repository calls haven&#8217;t changed from the earlier sequence. We still create the order, retrieve the inventory item, reserve the requested quantity, and persist the inventory modification. The difference is that Spring now coordinates those operations within a transaction rather than allowing each repository method to complete its own independent transaction.</p><p>When <code>placeOrder()</code> is called without an existing transaction, Spring begins a new transaction before executing the method. Both repositories participate in that transaction, meaning their database changes belong to the same operation and remain subject to the same final commit or rollback.</p><p>We&#8217;ve also added a quantity check at the beginning of the service method. This prevents an invalid quantity from reaching the database operation, while the validation inside <code>InventoryItem.reserve()</code> continues to protect inventory changes when that method is called from other locations.</p><p>Now let&#8217;s follow what happens if the purchase cannot be completed.</p><p>If the inventory lookup fails, <code>orElseThrow()</code> raises an <code>IllegalArgumentException</code>. That exception escapes <code>placeOrder()</code>, causing Spring to roll back the transaction. The purchase order inserted earlier is discarded because its insertion belongs to the same uncommitted transaction.</p><p>Insufficient stock follows the same general process. When <code>reserve()</code> throws an <code>IllegalStateException</code>, execution stops before the inventory update is completed. Spring detects the exception as it leaves the transactional method and rolls back the database changes associated with the purchase.</p><p>Both exceptions extend <code>RuntimeException</code>, which Spring treats as a reason to roll back under its default transaction configuration.</p><p>Successful execution follows a different outcome. After the repository operations complete and the service method returns, Spring proceeds with committing the transaction. Provided the database accepts the changes, the purchase order and updated inventory quantity become committed as part of the same transaction.</p><p>There&#8217;s an important distinction between calling <code>save()</code> and committing a transaction. In Spring Data JPA, <code>save()</code> requests that an entity be persisted or merged into the current persistence context. Hibernate determines when the corresponding SQL statements need to be executed, and those statements can execute before the transaction finishes.</p><p>Our <code>PurchaseOrder</code> entity provides a good example of this behavior. Because its identifier comes from <code>GenerationType.IDENTITY</code>, Hibernate generally needs to execute the insertion to retrieve the generated ID. That allows <code>order.getId()</code> to return the identifier before the enclosing transaction has committed.</p><p>Although the insertion has already happened at the SQL level, it can still be rolled back if a later operation fails. The database doesn&#8217;t treat the row as permanently committed merely because Hibernate executed the insertion.</p><p>The inventory entity behaves somewhat differently. After <code>findById()</code> retrieves the record inside the active transaction, the entity is managed by the JPA persistence context. When we call <code>reserve()</code>, we modify the entity&#8217;s quantity in memory, and Hibernate tracks that modification.</p><p>Because the entity is already managed, Hibernate can detect the changed quantity and persist it during synchronization with the database. Calling <code>inventoryRepository.save(item)</code> is valid, but it isn&#8217;t strictly required for an entity already managed within the transaction.</p><p>We could therefore remove the final <code>save()</code> call without changing how JPA tracks the quantity modification in this particular method. Keeping it in the example makes the intended persistence operation explicit, while the surrounding transaction remains responsible for the final commit.</p><p>Spring Boot 4 normally configures the transaction infrastructure automatically when the JPA starter, a configured data source, and the standard auto-configuration are present. We generally don&#8217;t need to register a transaction manager manually or add <code>@EnableTransactionManagement</code> to the application configuration.</p><p>The <code>@Transactional</code> annotation still comes from <code>org.springframework.transaction.annotation.Transactional</code>, and its basic behavior remains consistent with Spring Boot 3.</p><p>By placing the transaction boundary around <code>placeOrder()</code>, we&#8217;ve connected the database changes to the complete purchasing operation. An order that fails its inventory reservation won&#8217;t leave behind a committed purchase record, and successful inventory modifications are committed within the same transaction as their associated orders.</p><h4>Following the Transaction Lifecycle</h4><p>After adding <code>@Transactional</code>, we can examine how the transaction progresses from the initial service call through the final database commit. The timing is important because Hibernate can execute SQL statements before the transaction finishes, and certain failures aren&#8217;t detected until the remaining changes are synchronized with the database.</p><p>The process begins when a caller invokes <code>placeOrder()</code> through the Spring-managed service. Spring&#8217;s transaction proxy intercepts that call and delegates transaction management to the configured transaction manager. For a standard Spring Data JPA application connected to a single data source, this is normally a <code>JpaTransactionManager</code>.</p><p>Before the service method executes, the transaction manager establishes the transactional context. Hibernate then associates its persistence operations with that context, allowing the entity changes and repository calls to participate in the same database transaction.</p><p>As we enter <code>placeOrder()</code>, the quantity validation happens first. If the amount is invalid, the method throws an exception before creating an order or accessing the inventory repository.</p><p>For a valid quantity, we create a <code>PurchaseOrder</code> and pass it to the repository. Because the entity receives its identifier through <code>GenerationType.IDENTITY</code>, Hibernate generally executes the insertion at this point to retrieve the database-generated value.</p><p>The order row has now been inserted, but the surrounding transaction hasn&#8217;t committed. If the service encounters an exception that triggers rollback, the database can still discard that insertion.</p><p>Next, we retrieve the inventory item through <code>findById()</code>. Hibernate loads the corresponding entity into the current persistence context, allowing changes to its fields to be tracked for the duration of the transaction.</p><p>Calling <code>reserve()</code> modifies the <code>availableQuantity</code> field after checking that the requested quantity is valid. Hibernate doesn&#8217;t necessarily execute an <code>UPDATE</code> immediately when the Java method changes that value. Instead, the modification is tracked until Hibernate synchronizes the pending entity changes with the database.</p><p>This synchronization process is called a flush. During a flush, Hibernate executes the SQL statements needed to reflect pending changes in the database. Depending on the operations being performed and the configured flush mode, synchronization can happen before certain queries, through an explicit flush request, or during transaction completion.</p><p>Flushing and committing are different operations. Executing an <code>UPDATE</code> during a flush doesn&#8217;t mean the modified inventory quantity has been permanently committed.</p><p>If the transaction rolls back after that SQL statement executes, the database discards the uncommitted modification. This distinction becomes important when examining database logs because seeing an <code>INSERT</code> or <code>UPDATE</code> statement doesn&#8217;t prove that the transaction ultimately committed.</p><p>After the final repository call completes, <code>placeOrder()</code> returns the generated order ID to Spring&#8217;s transaction interceptor. From the service method&#8217;s perspective, the Java statements have finished successfully, but Spring still needs to complete the transaction.</p><p>The transaction manager begins that completion process by coordinating any remaining persistence operations. Hibernate synchronizes pending entity changes as needed, and the transaction manager requests that the database commit.</p><p>Certain failures can still occur during this stage.</p><p>Database constraints may reject an insertion or update, while concurrency checks can detect that an entity was modified by a competing transaction. These problems may surface during flushing rather than at the exact Java statement that originally changed the entity.</p><p>As a result, the service method body can finish without throwing an exception while the overall transactional invocation still fails during commit.</p><p>The caller receives a successful result only after Spring&#8217;s transaction interceptor finishes processing the commit. Returning the generated order ID from the service method doesn&#8217;t bypass that final transaction step.</p><p>Our inventory entity also includes an optimistic locking version field, which becomes important when multiple purchases affect the same inventory record.</p><p>Imagine two customers requesting the same product at nearly the same time. Their transactions could initially retrieve the same available quantity and version number, with both attempting to reserve inventory based on that earlier information.</p><p>When Hibernate updates a versioned entity, it checks the expected version against the version stored in the database. If a competing transaction has already committed a modification to that record, the version comparison detects the conflict.</p><p>The affected transaction then encounters an optimistic locking failure instead of silently overwriting the newer inventory quantity.</p><p>Depending on when Hibernate executes the update, the failure can occur during an earlier flush or during the synchronization performed as part of transaction completion. If the failure causes the transaction to roll back, the associated purchase order insertion is rolled back as well.</p><p>Through this behavior, <code>@Version</code> complements the transaction boundary. The transaction groups related database modifications into a single operation, while optimistic locking detects conflicting updates to the inventory record. Neither mechanism automatically retries a purchase that fails because of a concurrency conflict.</p><p>We should also keep in mind that database transactions only control operations participating in the transaction. If our service sends an email, invokes an external payment API, or publishes a message directly to an independent external system, rolling back the database transaction cannot undo those completed external actions.</p><p>For that reason, keeping the transaction focused on the database operations involved in the purchase is generally preferable. Holding a transaction open while waiting for an external network request can also keep database connections and locks occupied longer than necessary, potentially affecting concurrent operations.</p><p>In our ordering service, the transaction begins around <code>placeOrder()</code>, includes the purchase order insertion and inventory modification, and finishes when Spring processes the method&#8217;s outcome. Successful database changes commit within the same transaction, while failures that trigger rollback cause the participating changes to be discarded.</p><p>With the transaction boundary established at the service level, we can perform the related repository operations without allowing an earlier successful commit to leave the purchase partially recorded if a later database operation fails.</p><h3>Configuring Transaction Behavior</h3><p>With a transaction boundary established, Spring provides several settings that determine how the transaction responds to exceptions, handles read operations, and interacts with other transactional methods. These settings become important as service methods begin coordinating more database activity, particularly when certain failures need different treatment or multiple services participate in the same operation. Spring Boot 4 and Spring Framework 7 allow us to configure these behaviors through <code>@Transactional</code>, giving us control over transaction handling directly from the service methods responsible for database access.</p><h4>Controlling Rollback Rules</h4><p>Spring&#8217;s default transaction behavior depends on the type of exception that interrupts a transactional method. Java distinguishes between checked exceptions, which generally need to be handled or declared, and unchecked exceptions, which can propagate without being declared in the method signature.</p><p>By default, Spring rolls back transactions when an exception extending <code>RuntimeException</code> escapes the transactional method. Instances of <code>Error</code> also trigger rollback, while checked exceptions such as <code>IOException</code> don&#8217;t automatically cause rollback with the default configuration.</p><p>For our earlier ordering service, <code>IllegalArgumentException</code> and <code>IllegalStateException</code> are both runtime exceptions. If either escapes <code>placeOrder()</code>, Spring rolls back the transaction, preventing an unsuccessful purchase from leaving committed database changes.</p><p>Checked exceptions need some attention because they don&#8217;t receive that same default treatment.</p><p>Imagine we&#8217;re importing purchase records from a file and saving them through a repository. The method reads each record and inserts it into the database, but an <code>IOException</code> could interrupt the import after several records have already been processed.</p><p>Without an explicit rollback rule, Spring&#8217;s default behavior allows the transaction to commit despite the checked exception escaping the method. That could leave a partially imported collection of records in the database.</p><p>We can change this through the <code>rollbackFor</code> attribute of <code>@Transactional</code>. Let&#8217;s create a method that imports purchase orders from a file while treating an <code>IOException</code> as a reason to cancel the entire transaction.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;e09e2c9d-4d04-41c5-9665-57d784795d21&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">@Transactional(rollbackFor = IOException.class)
public void importOrders(Path file) throws IOException {
    try (BufferedReader reader = Files.newBufferedReader(file)) {
        String line;

        while ((line = reader.readLine()) != null) {
            String[] values = line.split(",");

            Long itemId = Long.parseLong(values[0]);
            int quantity = Integer.parseInt(values[1]);

            orderRepository.save(
                new PurchaseOrder(itemId, quantity)
            );
        }
    }
}</code></pre></div><p>For this method, we&#8217;re expecting a file containing an item identifier and quantity on each line, with the values divided by a comma. We&#8217;ll assume the input follows that format, keeping the focus on transaction behavior rather than file validation.</p><p>We begin by opening the file through <code>Files.newBufferedReader()</code>, which provides a <code>BufferedReader</code> for reading the records individually. Each line is divided into values, converted into the expected Java types, and passed to <code>orderRepository.save()</code> as a new <code>PurchaseOrder</code>.</p><p>The important addition is <code>rollbackFor = IOException.class</code>. If reading the file fails after several records have been saved within the transaction, the escaping <code>IOException</code> tells Spring to roll back those database changes rather than commit a partial import.</p><p>The transaction still rolls back for runtime exceptions because adding <code>rollbackFor</code> doesn&#8217;t remove Spring&#8217;s normal rollback behavior. Instead, it extends the exceptions covered by the transaction&#8217;s rollback rules.</p><p>We can also specify a broader exception type when the operation should be canceled for any checked exception extending <code>Exception</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;44b7db7d-767c-4741-be8f-4b87e3a870db&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">@Transactional(rollbackFor = Exception.class)</code></pre></div><p>Setting <code>rollbackFor</code> to <code>Exception.class</code> tells Spring to roll back for checked exceptions extending <code>Exception</code>, along with the unchecked exceptions already handled by default. This can be appropriate when a service operation shouldn&#8217;t commit after an exception escapes the method, although specifying individual exception types provides more control over which failures require rollback.</p><p>Spring Framework 7 also supports configuring rollback for all exceptions at the transaction management level through <code>@EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS)</code>. This capability was introduced in Spring Framework 6.2 and remains available with Spring Boot 4.</p><p>The global setting allows applications to adopt rollback behavior that includes checked exceptions without repeating <code>rollbackFor</code> across individual methods. It must be explicitly configured, though, because Spring&#8217;s default behavior still distinguishes checked exceptions from runtime exceptions.</p><p>There&#8217;s also an important difference between an exception escaping a method and an exception being caught inside it.</p><p>If we catch an exception before it escapes the transactional method, Spring&#8217;s transaction interceptor doesn&#8217;t automatically treat that caught exception as a reason to roll back.</p><p>Think about the ordering service encountering insufficient inventory. We could catch the exception from <code>reserve()</code> and return a result rather than allowing the exception to propagate:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;a174776f-c9ba-4ed9-b100-c83560c49c43&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">try {
    item.reserve(quantity);
} catch (IllegalStateException ex) {
    return null;
}</code></pre></div><p>The catch block prevents the <code>IllegalStateException</code> from leaving the transactional method. If no earlier operation has already marked the transaction for rollback, Spring sees a normal method return and proceeds toward committing the transaction.</p><p>Because the purchase order was inserted before the inventory reservation, that insertion could be committed despite the failed reservation. The returned <code>null</code> value doesn&#8217;t tell Spring that the database changes should be discarded.</p><p>Catching an exception doesn&#8217;t automatically cancel a transaction, so we need to be careful about handling failures inside methods that have already modified database records.</p><p>For failures that should cancel the entire operation, allowing the exception to propagate outside the transactional method is generally preferable. Spring can then apply the rollback rules, while the calling layer can handle the exception after the transaction has been completed.</p><p>Spring also provides a way to mark the current transaction for rollback manually through <code>TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()</code>. However, allowing exceptions to propagate generally keeps the service focused on its database operations without introducing direct calls to the transaction infrastructure.</p><p>We should also remember that an earlier database or repository failure may have already marked the transaction for rollback. Catching that exception doesn&#8217;t necessarily restore the transaction&#8217;s ability to commit.</p><p>To verify the intended behavior, we can write an integration test that invokes the service and inspects the database afterward. For an insufficient-inventory purchase, the test could record the existing order count, invoke the service with an unavailable quantity, and check that the count hasn&#8217;t increased.</p><p>Running that verification without a transaction managed by the test itself allows us to examine the service&#8217;s transaction behavior rather than relying on an automatic test rollback.</p><h4>Marking Read Operations as Read-Only</h4><p>Service methods don&#8217;t always need to insert records or modify existing data. We regularly retrieve information for controllers, reports, and other application features, and Spring allows us to identify transactions intended exclusively for reading from the database. The <code>readOnly</code> attribute of <code>@Transactional</code> communicates that intention to the transaction infrastructure. With JPA, this can reduce the persistence processing associated with tracking modifications to entities that we only intend to retrieve.</p><p>Let&#8217;s create an <code>InventoryQueryService</code> that returns the available quantity of an inventory item without modifying its stored values:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;9a0ec2ff-907d-49f1-9905-69146f6da7c1&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class InventoryQueryService {

    private final InventoryRepository inventoryRepository;

    public InventoryQueryService(
            InventoryRepository inventoryRepository) {
        this.inventoryRepository = inventoryRepository;
    }

    @Transactional(readOnly = true)
    public int getAvailableQuantity(Long itemId) {
        InventoryItem item = inventoryRepository.findById(itemId)
            .orElseThrow(() -&gt;
                new IllegalArgumentException("Item not found")
            );

        return item.getAvailableQuantity();
    }
}</code></pre></div><p>We&#8217;ve defined a Spring-managed service with a repository dependency and a public method for retrieving inventory information. The <code>getAvailableQuantity()</code> method calls <code>findById()</code> to locate the requested entity and throws an exception if the identifier doesn&#8217;t correspond to an existing record.</p><p>After retrieving the entity, we return the value from <code>getAvailableQuantity()</code> without changing any of its fields. The repository handles the database lookup, while the service determines what information should be returned to the caller.</p><p>The <code>@Transactional(readOnly = true)</code> annotation tells Spring that this method is intended for reading data. With Hibernate, the read-only transaction setting can reduce processing associated with detecting modifications to loaded entities, including changes to the flush behavior.</p><p>Spring also communicates the read-only setting to the underlying database infrastructure where supported, allowing the database or JDBC driver to apply its own behavior.</p><p>However, <code>readOnly = true</code> doesn&#8217;t universally prevent database writes.</p><p>The annotation communicates a transaction preference rather than providing a guaranteed restriction against modifications. Depending on the database and transaction manager, certain writes may be rejected, while other configurations don&#8217;t enforce that restriction.</p><p>We should therefore keep methods marked as read-only focused on retrieval operations instead of depending on the annotation to prevent accidental updates.</p><p>Spring Data JPA already provides read-only transaction settings for inherited repository reading methods, which is worth keeping in mind before adding annotations throughout a service.</p><p>Methods such as <code>findById()</code> and <code>findAll()</code> inherit transactional configuration from Spring Data JPA&#8217;s repository implementation. For ordinary calls to those methods without an existing transaction, the repository can establish its own read-only transaction.</p><p>This means we don&#8217;t necessarily need an additional service-level read-only transaction for a method that performs a single repository lookup.</p><p>The service annotation becomes more valuable when several database queries need to execute within a common transaction boundary.</p><p>Imagine we&#8217;re building a reporting service that retrieves purchase orders, loads their associated inventory records, and calculates information from the retrieved entities. By placing a read-only transaction around the service method, we keep those repository operations within the same transaction and JPA persistence context.</p><p>Sharing the persistence context can also help when accessing lazily loaded entity relationships during service execution, provided the required entities remain associated with the active persistence context.</p><p>Still, a read-only transaction doesn&#8217;t automatically guarantee that every query observes an identical database snapshot. The visibility of concurrent changes depends on the database&#8217;s transaction isolation level.</p><p>For our inventory query service, the main benefit is communicating that the method retrieves data without intending to modify it. This gives Spring and Hibernate information they can use when managing the persistence context and database interaction, while the service remains responsible for performing only the intended reading operations.</p><h4>Recognizing Calls That Bypass the Transaction Proxy</h4><p>Spring normally applies <code>@Transactional</code> through proxies that intercept calls to Spring-managed service methods. The proxy allows Spring to begin or join a transaction before passing execution to the actual method, applying the transaction settings declared on that method.</p><p>This mechanism explains why an annotated method can sometimes execute without its expected transaction settings being applied. What matters is how execution reaches the method.</p><p>When we invoke a transactional service through an injected Spring-managed bean, the call passes through Spring&#8217;s proxy. The transaction infrastructure can then inspect the annotation and apply the associated settings before the method executes. Calls between methods inside the same service instance behave differently because they don&#8217;t pass through that proxy.</p><p>Returning to our <code>OrderService</code>, imagine we add a method that internally calls the existing <code>placeOrder()</code> method:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;6c352768-eef6-465a-8c6e-72b7c747ff92&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public Long submitInternalOrder(Long itemId, int quantity) {
    return placeOrder(itemId, quantity);
}</code></pre></div><p>We&#8217;ve introduced <code>submitInternalOrder()</code> as an ordinary public method without <code>@Transactional</code>. Its implementation calls the existing <code>placeOrder()</code> method directly from the same service instance.</p><p>If another component invokes <code>submitInternalOrder()</code>, Spring doesn&#8217;t establish a service-level transaction for that outer method because it has no transactional annotation.</p><p>The internal call to <code>placeOrder()</code> also doesn&#8217;t pass through the transaction proxy. As a result, the <code>@Transactional</code> annotation on <code>placeOrder()</code> isn&#8217;t applied to this invocation. That can recreate the earlier problem where repository operations execute within their individual transactions rather than participating in a transaction covering the entire purchase.</p><p>The annotation on <code>placeOrder()</code> remains valid, but Spring needs the call to reach the method through its transaction infrastructure for those settings to be applied.</p><p>We can address this by moving the calling operation into a different Spring-managed service. Through dependency injection, we&#8217;ll receive a reference to the transactional service that allows Spring to intercept the call.</p><p>For our ordering operation, let&#8217;s introduce a <code>CheckoutService</code> responsible for forwarding purchase requests to <code>OrderService</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;7997c13c-3cfd-4b02-b4fc-65320b51ee66&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import org.springframework.stereotype.Service;

@Service
public class CheckoutService {

    private final OrderService orderService;

    public CheckoutService(OrderService orderService) {
        this.orderService = orderService;
    }

    public Long submit(Long itemId, int quantity) {
        return orderService.placeOrder(itemId, quantity);
    }
}</code></pre></div><p>The <code>CheckoutService</code> constructor receives the Spring-managed <code>OrderService</code> bean. When we call <code>submit()</code>, it forwards the purchase request through that injected reference rather than calling a method on its own instance.</p><p>Spring can intercept the call to <code>orderService.placeOrder()</code> and apply the <code>@Transactional</code> annotation before the ordering operation executes.</p><p>This keeps the purchase transaction in the service responsible for modifying the database, while the checkout service coordinates access to that operation.</p><p>We could also place <code>@Transactional</code> on the outer method when it represents the complete database operation. The appropriate location depends on which method should establish the transaction boundary.</p><p>There&#8217;s an important distinction involving transactions that are already active.</p><p>Calling an annotated method from the same instance doesn&#8217;t cancel an existing transaction. If the outer method was invoked through Spring&#8217;s proxy and already established a transaction, the internal method executes within that existing transactional context.</p><p>What the internal call cannot do through ordinary proxy interception is apply the called method&#8217;s own transaction settings independently.</p><p>This becomes relevant when the called method declares different propagation behavior, a different isolation level, or other transactional attributes. Those settings aren&#8217;t applied merely because execution enters the annotated method through an internal Java call.</p><p>Private helper methods have a related limitation. Spring&#8217;s ordinary proxy-based transaction management cannot intercept private method calls, so placing <code>@Transactional</code> on a private helper doesn&#8217;t establish the expected transaction boundary.</p><p>Keeping transactional service methods public and calling them through Spring-managed beans gives us a consistent way to apply the intended transaction settings.</p><p>We also need to pay attention to thread boundaries. Transactions managed through the ordinary JPA transaction manager are associated with the current execution thread. If a service starts database operations through <code>@Async</code>, a new thread, or an executor, the caller&#8217;s transaction isn&#8217;t automatically transferred into that execution.</p><p>Database operations running asynchronously therefore need an appropriate transaction boundary of their own.</p><p>This distinction is important for services that perform background processing because an annotation on the original calling method doesn&#8217;t automatically cover database operations executed later in a different thread.</p><h4>Controlling Transaction Participation</h4><p>Transactions can involve calls across multiple services, and Spring needs to determine how those methods participate when a transaction is already active.</p><p>The <code>propagation</code> attribute of <code>@Transactional</code> controls this behavior. It determines if a transactional method joins an existing transaction, establishes an independent transaction, or follows a different participation rule.</p><p>Spring&#8217;s default propagation setting is <code>Propagation.REQUIRED</code>.</p><p>With <code>REQUIRED</code>, Spring joins an existing transaction when an appropriate transaction is available. If the calling thread doesn&#8217;t already have a transaction managed by the selected transaction manager, Spring starts a new transaction.</p><p>We can explicitly declare that setting through the annotation:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;5f0b1134-1233-42a9-9c95-25a9f93e1590&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">@Transactional(propagation = Propagation.REQUIRED)</code></pre></div><p>This declaration provides the same propagation behavior as the default <code>@Transactional</code> annotation. We don&#8217;t normally need to specify it unless we&#8217;re documenting a particular transaction requirement or comparing it with a different propagation option.</p><p>Think about our ordering service calling a second transactional service that updates a related database record.</p><p>If both methods use <code>REQUIRED</code>, the second service participates in the transaction already established by the first. Its database changes belong to the same transaction, so an eventual rollback can discard changes made by both services.</p><p>This behavior is valuable when multiple service methods contribute to the same database operation, allowing the outer service to coordinate their database changes within a shared transaction.</p><p>There is, however, an important consequence when an inner transactional method encounters an exception.</p><p>If the inner method marks the shared transaction for rollback, catching its exception in the outer method doesn&#8217;t automatically make the transaction eligible for commit again.</p><p>The outer method can finish normally and attempt to commit, but Spring recognizes that the transaction has already been marked for rollback. In that situation, the outer caller can receive an <code>UnexpectedRollbackException</code>, indicating that its attempted commit couldn&#8217;t succeed because the shared transaction was rolled back.</p><p>This prevents callers from receiving an apparently successful transaction result when the database changes were actually discarded.</p><p>Spring also provides <code>Propagation.REQUIRES_NEW</code> for operations that need an independent transaction:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;75191f89-b5cd-457d-a9b2-9edd6a3ab9c2&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">@Transactional(propagation = Propagation.REQUIRES_NEW)</code></pre></div><p>With <code>REQUIRES_NEW</code>, Spring begins a new transaction for the annotated method rather than participating in an existing transaction. If an outer transaction is active, Spring suspends its transaction context while the independent transaction executes, provided the transaction manager supports that behavior.</p><p>The new transaction can commit or roll back independently of the outer transaction.</p><p>This can be appropriate for certain auditing operations where the application needs to record a failure even if the business operation that produced it is rolled back.</p><p>For instance, we could create an auditing service that stores an event through an <code>AuditRepository</code>, giving the audit operation its own transaction:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;0d201dad-0dd8-4c37-be8f-4854c2647f94&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">@Service
public class AuditService {

    private final AuditRepository auditRepository;

    public AuditService(AuditRepository auditRepository) {
        this.auditRepository = auditRepository;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void recordFailure(Long orderId, String reason) {
        auditRepository.save(
            new AuditEntry(orderId, reason)
        );
    }
}</code></pre></div><p>For this example, we&#8217;re assuming <code>AuditEntry</code> is a JPA entity representing a stored audit event and <code>AuditRepository</code> is its corresponding Spring Data JPA repository.</p><p>We&#8217;ve placed <code>REQUIRES_NEW</code> on <code>recordFailure()</code> so the audit entry can be persisted within its own transaction. If the method is invoked through the Spring-managed <code>AuditService</code> proxy while an ordering transaction is active, Spring suspends the ordering transaction and establishes a new transaction for the audit entry.</p><p>After the audit operation completes, Spring resumes the suspended transaction.</p><p>If the audit transaction commits successfully, its record isn&#8217;t automatically rolled back when the original ordering transaction later fails. Similarly, a rollback of the audit transaction doesn&#8217;t automatically mark the suspended ordering transaction for rollback.</p><p>The outcome still depends on how exceptions propagate through the calling service, so an audit failure that escapes into the outer transactional method can affect that method&#8217;s rollback behavior.</p><p>We also need to account for database connections when starting independent transactions.</p><p>The outer transaction may retain its database connection while the inner transaction requires an additional connection from the connection pool. If several requests use <code>REQUIRES_NEW</code> concurrently, the application can exhaust its available connections if the pool doesn&#8217;t have enough capacity.</p><p>For that reason, independent transactions are generally best reserved for operations that actually require their own commit or rollback behavior.</p><p>The proxy limitation discussed earlier also applies to <code>REQUIRES_NEW</code>. Calling an annotated method internally from the same service instance doesn&#8217;t create the independent transaction through ordinary Spring proxy-based transaction management. The method must be invoked through the appropriate transactional proxy for its propagation setting to take effect.</p><p>For most service operations, the default <code>REQUIRED</code> propagation setting remains appropriate because related database changes should generally participate in the same transaction.</p><p>Transaction propagation should also be distinguished from transaction isolation. Propagation controls how a method participates in an existing transaction, while isolation governs how concurrent transactions interact with database changes. Spring&#8217;s default isolation setting delegates to the database, so the resulting behavior depends on the database configuration.</p><p>We can configure an isolation level through the <code>isolation</code> attribute of <code>@Transactional</code>, but changing that setting doesn&#8217;t replace database constraints or concurrency controls.</p><p>In the ordering service, the <code>@Version</code> field on <code>InventoryItem</code> remains valuable because multiple customers can attempt to reserve the same inventory record concurrently. The transaction groups the related purchase modifications, while optimistic locking detects conflicting updates that could cause an outdated inventory quantity to overwrite a newer value.</p><p>When choosing the appropriate propagation setting for each service method, we control which database operations share a transaction and which need independent commit behavior. Spring handles transaction participation according to those settings, while the service methods remain responsible for the database operations they perform.</p><h3>Conclusion</h3><p>Managing transactions in Spring Boot 4 starts with placing <code>@Transactional</code> around service methods that need related database operations to commit or roll back as a single unit. From there, we can adjust rollback rules for different exceptions, mark retrieval methods as read-only, and control how transactions behave across service calls. Keeping transaction boundaries in the right places and accounting for Spring&#8217;s proxy behavior helps prevent partial updates while giving us more control over how database changes are committed.</p><ol><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-framework%2Freference%2Fdata-access%2Ftransaction%2Fdeclarative.html">Spring Framework Declarative Transaction Management</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-framework%2Freference%2Fdata-access%2Ftransaction%2Fdeclarative%2Fannotations.html">Spring Framework @Transactional Annotation Settings</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-framework%2Freference%2Fdata-access%2Ftransaction%2Fdeclarative%2Frolling-back.html">Spring Framework Transaction Rollback Rules</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-framework%2Freference%2Fdata-access%2Ftransaction%2Fdeclarative%2Ftx-propagation.html">Spring Framework Transaction Propagation</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-data%2Fjpa%2Freference%2Fjpa%2Ftransactions.html">Spring Data JPA Transactionality</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2F%3Futm_source%3Dsubstack%26utm_medium%3Demail%26utm_content%3Dshare%26action%3Dshare"><span>Share Alexander Obregon's Substack</span></a></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21MMyS%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F83cf90da-0522-4c0f-a7b8-8ce42cdca350_276x276.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!MMyS!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F83cf90da-0522-4c0f-a7b8-8ce42cdca350_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!MMyS!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F83cf90da-0522-4c0f-a7b8-8ce42cdca350_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!MMyS!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F83cf90da-0522-4c0f-a7b8-8ce42cdca350_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!MMyS!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F83cf90da-0522-4c0f-a7b8-8ce42cdca350_276x276.png 1456w" sizes="auto, 100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21MMyS%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F83cf90da-0522-4c0f-a7b8-8ce42cdca350_276x276.png" width="276" height="276" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/83cf90da-0522-4c0f-a7b8-8ce42cdca350_276x276.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:276,&quot;width&quot;:276,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!MMyS!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F83cf90da-0522-4c0f-a7b8-8ce42cdca350_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!MMyS!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F83cf90da-0522-4c0f-a7b8-8ce42cdca350_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!MMyS!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F83cf90da-0522-4c0f-a7b8-8ce42cdca350_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!MMyS!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F83cf90da-0522-4c0f-a7b8-8ce42cdca350_276x276.png 1456w" sizes="auto, 100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Ficons8.com%2Ficon%2F90519%2Fspring-boot">Spring Boot</a> icon by <a href="iframe.php?url=https%3A%2F%2Ficons8.com%2F">Icons8</a></figcaption></figure></div>]]></content:encoded></item><item><title><![CDATA[Weekly Recap – October 25th, 2026]]></title><description><![CDATA[More Spring Boot and SQL]]></description><link>https://alexanderobregon.substack.com/p/weekly-recap-october-25th-2026</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/weekly-recap-october-25th-2026</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Sun, 25 Oct 2026 12:15:33 GMT</pubDate><content:encoded><![CDATA[<p>This past week, I posted two new articles, one covering Spring Boot 4 and the other focused on SQL Server. For Spring Boot, I went over how to manage profiles for different environments, such as development, testing, and production. I also covered how to unpivot data in SQL Server, including how to turn columns into rows when working with data stored in a wider format.</p><p>If you&#8217;re new or want to know more about what I do, check out my <a href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fabout">About Me page.</a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fsubscribe%3F"><span>Subscribe now</span></a></p><h4><strong>Substack Articles</strong></h4><div class="digest-post-embed" data-attrs="{&quot;nodeId&quot;:&quot;3afed79a-bd9c-4114-9b98-5ae86446171a&quot;,&quot;caption&quot;:&quot;As a Spring Boot application moves through development, testing, staging, and production, its configuration usually needs to change along the way. Development may call for detailed logging and a local database, while production typically requires different database connections, logging levels, and processing limits. Spring Boot profiles let &#8230;&quot;,&quot;cta&quot;:null,&quot;showBylines&quot;:true,&quot;showDescription&quot;:true,&quot;showImage&quot;:true,&quot;size&quot;:&quot;sm&quot;,&quot;isEditorNode&quot;:true,&quot;title&quot;:&quot;How to Manage Spring Boot 4 Profiles for Different Environments&quot;,&quot;publishedBylines&quot;:[{&quot;id&quot;:185785576,&quot;name&quot;:&quot;Alexander Obregon&quot;,&quot;bio&quot;:&quot;I publish on Medium and use Substack to share recaps, exclusive articles, and a way to support my programming-focused content.&quot;,&quot;photo_url&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8905d669-ef92-4534-9c0c-f486ad372c94_1905x1905.jpeg&quot;,&quot;is_guest&quot;:false,&quot;bestseller_tier&quot;:null}],&quot;post_date&quot;:&quot;2026-10-21T22:17:08.401Z&quot;,&quot;cover_image&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/9d7f122f-7a36-4b66-a679-bad48ccd7dae_480x480.png&quot;,&quot;cover_image_alt&quot;:null,&quot;canonical_url&quot;:&quot;https://alexanderobregon.substack.com/p/how-to-manage-spring-boot-4-profiles&quot;,&quot;section_name&quot;:&quot;Java and JVM&quot;,&quot;video_upload_id&quot;:null,&quot;id&quot;:221553095,&quot;type&quot;:&quot;newsletter&quot;,&quot;reaction_count&quot;:1,&quot;comment_count&quot;:0,&quot;publication_id&quot;:4719466,&quot;publication_name&quot;:&quot;Alexander Obregon's Substack&quot;,&quot;publication_logo_url&quot;:&quot;&quot;,&quot;belowTheFold&quot;:false,&quot;youtube_url&quot;:null,&quot;show_links&quot;:null,&quot;feed_url&quot;:null}"></div><div class="digest-post-embed" data-attrs="{&quot;nodeId&quot;:&quot;4786abb6-dbc1-4690-84d0-520f33b88619&quot;,&quot;caption&quot;:&quot;In my last SQL article, we went over Pivot Queries in SQL Server, where we turned row values into columns to summarize data. Now we&#8217;re going the other direction with UNPIVOT, which takes values stored across multiple columns and turns them into rows. Some tables store related information across individual columns, like monthly sales figures,&#8230;&quot;,&quot;cta&quot;:null,&quot;showBylines&quot;:true,&quot;showDescription&quot;:true,&quot;showImage&quot;:true,&quot;size&quot;:&quot;sm&quot;,&quot;isEditorNode&quot;:true,&quot;title&quot;:&quot;Unpivot Data with SQL Server&quot;,&quot;publishedBylines&quot;:[{&quot;id&quot;:185785576,&quot;name&quot;:&quot;Alexander Obregon&quot;,&quot;bio&quot;:&quot;I publish on Medium and use Substack to share recaps, exclusive articles, and a way to support my programming-focused content.&quot;,&quot;photo_url&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8905d669-ef92-4534-9c0c-f486ad372c94_1905x1905.jpeg&quot;,&quot;is_guest&quot;:false,&quot;bestseller_tier&quot;:null}],&quot;post_date&quot;:&quot;2026-10-23T16:54:33.014Z&quot;,&quot;cover_image&quot;:&quot;https://substackcdn.com/image/fetch/$s_!GC4K!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fee83428c-cd8b-4b91-bdd5-756fd8f9b22f_306x306.png&quot;,&quot;cover_image_alt&quot;:null,&quot;canonical_url&quot;:&quot;https://alexanderobregon.substack.com/p/unpivot-data-with-sql-server&quot;,&quot;section_name&quot;:&quot;SQL&quot;,&quot;video_upload_id&quot;:null,&quot;id&quot;:221568826,&quot;type&quot;:&quot;newsletter&quot;,&quot;reaction_count&quot;:0,&quot;comment_count&quot;:0,&quot;publication_id&quot;:4719466,&quot;publication_name&quot;:&quot;Alexander Obregon's Substack&quot;,&quot;publication_logo_url&quot;:&quot;&quot;,&quot;belowTheFold&quot;:false,&quot;youtube_url&quot;:null,&quot;show_links&quot;:null,&quot;feed_url&quot;:null}"></div><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2F%3Futm_source%3Dsubstack%26utm_medium%3Demail%26utm_content%3Dshare%26action%3Dshare"><span>Share Alexander Obregon's Substack</span></a></p>]]></content:encoded></item><item><title><![CDATA[Unpivot Data with SQL Server]]></title><description><![CDATA[In my last SQL article, we went over Pivot Queries in SQL Server, where we turned row values into columns to summarize data.]]></description><link>https://alexanderobregon.substack.com/p/unpivot-data-with-sql-server</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/unpivot-data-with-sql-server</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Fri, 23 Oct 2026 16:54:33 GMT</pubDate><enclosure url="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21GC4K%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fee83428c-cd8b-4b91-bdd5-756fd8f9b22f_306x306.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%2147uu%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F69daedd2-c394-4c72-beb1-2d54d6c1b0be_800x373.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!47uu!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F69daedd2-c394-4c72-beb1-2d54d6c1b0be_800x373.png 424w, https://substackcdn.com/image/fetch/$s_!47uu!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F69daedd2-c394-4c72-beb1-2d54d6c1b0be_800x373.png 848w, https://substackcdn.com/image/fetch/$s_!47uu!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F69daedd2-c394-4c72-beb1-2d54d6c1b0be_800x373.png 1272w, https://substackcdn.com/image/fetch/$s_!47uu!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F69daedd2-c394-4c72-beb1-2d54d6c1b0be_800x373.png 1456w" sizes="100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%2147uu%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F69daedd2-c394-4c72-beb1-2d54d6c1b0be_800x373.png" width="800" height="373" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/69daedd2-c394-4c72-beb1-2d54d6c1b0be_800x373.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:373,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!47uu!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F69daedd2-c394-4c72-beb1-2d54d6c1b0be_800x373.png 424w, https://substackcdn.com/image/fetch/$s_!47uu!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F69daedd2-c394-4c72-beb1-2d54d6c1b0be_800x373.png 848w, https://substackcdn.com/image/fetch/$s_!47uu!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F69daedd2-c394-4c72-beb1-2d54d6c1b0be_800x373.png 1272w, https://substackcdn.com/image/fetch/$s_!47uu!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F69daedd2-c394-4c72-beb1-2d54d6c1b0be_800x373.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Fcommons.wikimedia.org%2Fwiki%2FFile%3ASql_data_base_with_logo.png">Image Source</a></figcaption></figure></div><p>In my last SQL article, we went over Pivot Queries in SQL Server, where we turned row values into columns to summarize data. Now we&#8217;re going the other direction with <code>UNPIVOT</code>, which takes values stored across multiple columns and turns them into rows. Some tables store related information across individual columns, like monthly sales figures, survey responses, or measurements collected over time. While this structure can make individual records convenient to read, filtering, grouping, or summarizing values across those columns can become more complicated. With <code>UNPIVOT</code>, we can move those values into a single column while keeping their original column names as identifiers, giving us a format better suited to reporting, aggregation, and further SQL queries without changing the original table.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fsubscribe%3F"><span>Subscribe now</span></a></p><h3>Preparing the Source Data</h3><p>Before applying <code>UNPIVOT</code>, we need to look at how the original data is organized and which columns contain the values we want to transform. The columns supplied by the source table or query determine how we can perform that transformation, so we need to pay attention to their names, data types, and relationships. We&#8217;ll use a table containing monthly sales figures to examine how wide data is stored and what needs to be prepared before moving those values into rows.</p><h4>What Wide Data Looks Like</h4><p>Some database tables store related information across multiple columns rather than keeping every measurement in its own row. This is commonly called a wide table, where each record contains several values representing different categories, periods, or attributes. We can encounter this structure in sales reports, financial records, survey results, or other data where individual columns represent specific measurements.</p><p>Think about a table containing monthly sales figures for different products. Each product has its own record for a particular year, with January, February, and March stored as individual columns. This allows someone viewing the table to read several months of sales figures for a product without needing to look through multiple records.</p><p>We can create a table named <code>MonthlySales</code> to represent this structure, with two identifying columns followed by three monthly sales columns:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;c8f5cb73-9080-46cf-8ac6-c4e754b3f310&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">CREATE TABLE dbo.MonthlySales
(
    ProductID INT NOT NULL,
    SalesYear INT NOT NULL,
    January DECIMAL(12, 2) NULL,
    February DECIMAL(12, 2) NULL,
    March DECIMAL(12, 2) NULL
);

INSERT INTO dbo.MonthlySales
    (ProductID, SalesYear, January, February, March)
VALUES
    (101, 2026, 1250.00, 1400.00, 1600.00),
    (102, 2026, 900.00, NULL, 1150.00);</code></pre></div><p>We start by creating <code>dbo.MonthlySales</code> with five columns, then insert two records containing sales figures for different products. The <code>ProductID</code> column identifies which product each record belongs to, while <code>SalesYear</code> tells us the year associated with those figures. Both columns provide context for the sales amounts, allowing us to distinguish records belonging to different products or years.</p><p>Meanwhile, the <code>January</code>, <code>February</code>, and <code>March</code> columns hold the actual sales amounts. Each column represents a particular month, so its name tells us which period the recorded amount belongs to. We define these columns as <code>DECIMAL(12, 2)</code> to store numeric values with two decimal places, which is appropriate for the sales amounts in our example.</p><p>Notice that the month names are part of the table structure rather than values stored in a month column. This difference becomes important when we want to query sales across different periods because SQL Server treats January, February, and March as individual fields.</p><p>We can retrieve the original records with a regular <code>SELECT</code> statement to see how the information is arranged:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;e3f19b49-2654-4a58-9d9d-afa83baa9b09&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    ProductID,
    SalesYear,
    January,
    February,
    March
FROM dbo.MonthlySales
ORDER BY ProductID;</code></pre></div><p>The query retrieves all five columns from <code>MonthlySales</code> and orders the records by <code>ProductID</code>. We receive two rows, each containing the sales figures for an individual product. Product <code>101</code> has amounts recorded for all three months, while product <code>102</code> has values for January and March but contains <code>NULL</code> for February.</p><p>Having these columns side by side is convenient when someone wants to compare the monthly figures for a particular product. If we needed to retrieve January sales for product <code>101</code>, we could reference the <code>January</code> column directly without involving the other months.</p><p>However, queries that need to treat months as a category require more SQL expressions. Calculating sales totals for each month would involve referencing the corresponding monthly columns, while filtering records according to a particular month would depend on which column held that information. The same limitation becomes more noticeable as additional months are stored in the table. If the structure eventually included all twelve months, queries covering the entire year would need to account for twelve different columns rather than referring to a single month field.</p><p>This brings us to the distinction between wide and long data. Wide data keeps related measurements across columns, while long data stores those measurements vertically, with a column identifying the category and a second column containing its value.</p><p>For our sales example, the long representation would have a month field containing values such as January or February, followed by a field containing the associated sales amount. Product identifiers and years would still provide context for those measurements, allowing us to identify the original record associated with each value.</p><p>SQL Server&#8217;s <code>UNPIVOT</code> operator allows us to produce that arrangement from the existing wide table without permanently changing its structure. Before applying it, though, we need to confirm that the columns containing the monthly figures have compatible definitions.</p><h4>Preparing Compatible Columns</h4><p>The columns selected for an <code>UNPIVOT</code> operation must have matching data types because their values will eventually be placed into a single output column. If those source columns have incompatible definitions, SQL Server can return a type conflict instead of completing the query.</p><p>For our <code>MonthlySales</code> table, we defined January, February, and March as <code>DECIMAL(12, 2)</code>. This means every monthly value follows the same numeric definition, with a precision of twelve digits and a scale of two decimal places.</p><p>Precision represents the maximum total number of digits stored in a decimal value, while scale determines how many of those digits appear after the decimal point. With <code>DECIMAL(12, 2)</code>, we can store up to ten digits before the decimal point and two afterward. Keeping the monthly columns consistent allows their values to be placed into a common output column without requiring additional conversions. SQL Server also requires matching lengths when dealing with character columns. For instance, <code>VARCHAR(50)</code> and <code>VARCHAR(100)</code> aren&#8217;t interchangeable within the same <code>UNPIVOT</code> column list merely because both store character data. Their declared lengths differ, which prevents SQL Server from treating them as identical source types.</p><p>The same concern applies to numeric columns defined with different precision or scale values. If January were declared as <code>DECIMAL(10, 2)</code> and February as <code>DECIMAL(12, 2)</code>, their definitions wouldn&#8217;t match, despite both being capable of storing monetary amounts.</p><p>Before preparing a query against an existing table, we can inspect the column definitions through SQL Server&#8217;s catalog views. This allows us to check the metadata directly rather than relying on the values currently stored in each column:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;5c9f0524-4b64-4235-af84-4c2a54807955&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    c.name AS ColumnName,
    t.name AS DataType,
    c.precision AS NumericPrecision,
    c.scale AS NumericScale,
    c.is_nullable AS AllowsNull
FROM sys.columns AS c
INNER JOIN sys.types AS t
    ON c.user_type_id = t.user_type_id
WHERE c.object_id = OBJECT_ID(N'dbo.MonthlySales')
    AND c.name IN ('January', 'February', 'March')
ORDER BY c.column_id;</code></pre></div><p>We begin with <code>sys.columns</code>, which contains metadata about the columns defined within database objects, including their numeric precision, scale, and nullability. By joining it with <code>sys.types</code>, we can retrieve the corresponding data type names alongside those properties.</p><p>Next, we use <code>OBJECT_ID</code> to identify our <code>MonthlySales</code> table and restrict the results to January, February, and March through the <code>IN</code> condition. The <code>ORDER BY</code> clause follows their original column positions, making it convenient to compare their definitions in the order they appear within the table.</p><p>For the table we created, all three columns return <code>decimal</code> as their data type, a precision of <code>12</code>, and a scale of <code>2</code>. Their <code>AllowsNull</code> values are also <code>1</code>, indicating that each column permits <code>NULL</code> values.</p><p>We don&#8217;t need matching nullability across every source column for <code>UNPIVOT</code> to accept them. Nullability determines if a particular column can contain missing values, while data type compatibility determines if values from different columns can be placed into the same output field.</p><p>If the source columns have different data types, we can prepare them through a derived table or common table expression before applying the operator. SQL Server&#8217;s <code>CAST</code> or <code>CONVERT</code> functions allow us to give the selected values a common data type, provided their original contents support the conversion.</p><p>For numeric sales figures, converting the relevant columns to the same <code>DECIMAL</code> precision and scale would allow us to treat them consistently. We should select a type with enough capacity to represent the existing values without unwanted rounding or loss of information. Character columns follow a similar principle, where the chosen length needs to accommodate the values being converted.</p><p>Not every column in the source table needs to share the same data type. Our <code>ProductID</code> and <code>SalesYear</code> columns happen to be integers, but they aren&#8217;t part of the monthly values being transformed. These identifying columns can retain their existing definitions because their purpose is to associate the measurements with the original records.</p><p>We also need to distinguish the columns that identify a record from those containing the measurements. For our sales table, <code>ProductID</code> and <code>SalesYear</code> establish the context, while January, February, and March contain the amounts we intend to turn into rows. Identifying these groups before applying <code>UNPIVOT</code> helps us select the intended columns without accidentally treating product identifiers or years as sales measurements.</p><p>Finally, our sample contains a <code>NULL</code> value for February under product <code>102</code>. This represents a missing or unknown sales amount rather than a recorded value of zero. We want to preserve that distinction in the source data because replacing missing information with zero would assign a meaning that wasn&#8217;t present in the original record.</p><p>SQL Server has specific behavior for these missing values during <code>UNPIVOT</code>, which we&#8217;ll examine when applying the operator. The monthly columns now have matching numeric definitions, and we&#8217;ve identified which fields contain the measurements and which provide the context needed to associate them with their original records.</p><h3>Transforming Data with UNPIVOT</h3><p>With our monthly sales data prepared, we can now use <code>UNPIVOT</code> to turn the individual month columns into rows. The operator performs this transformation within the <code>FROM</code> clause, allowing us to retrieve the original records in a different format without modifying the source table. We&#8217;ll start with the basic syntax, examine the resulting records, and then look at how missing values and additional SQL operations affect the results.</p><h4>Writing the UNPIVOT Query</h4><p>We can begin by transforming the three monthly sales columns from our <code>MonthlySales</code> table. Rather than retrieving January, February, and March as individual fields, we want the query to return a month name alongside its corresponding sales amount.</p><p>The <code>UNPIVOT</code> operator lets us define which columns should become rows, what to call the new column containing their names, and where their original values should appear.</p><p>Our first query will transform the monthly sales figures while retaining the product identifiers and sales years:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;8088ae21-36d6-4da2-b519-d98353e98c94&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    ProductID,
    SalesYear,
    SalesMonth,
    SalesAmount
FROM dbo.MonthlySales
UNPIVOT
(
    SalesAmount FOR SalesMonth IN (January, February, March)
) AS SalesRows
ORDER BY ProductID, SalesMonth;</code></pre></div><p>We start by selecting <code>ProductID</code> and <code>SalesYear</code>, which identify the original sales records. The next two fields, <code>SalesMonth</code> and <code>SalesAmount</code>, are created through the <code>UNPIVOT</code> operation and contain the transformed information.</p><p>Inside the <code>UNPIVOT</code> clause, <code>SalesAmount</code> defines the output column that receives the values originally stored under January, February, and March. Because all three source columns have the same <code>DECIMAL(12, 2)</code> definition, their values can be placed into this common numeric field.</p><p>Next, <code>FOR SalesMonth</code> defines the column that will hold the original column names. Instead of having January, February, and March across the top of the result, we&#8217;ll have their names stored as text values within <code>SalesMonth</code>.</p><p>The <code>IN (January, February, March)</code> portion tells SQL Server exactly which source columns to transform. Only the columns included in this list participate in the operation, so adding April or May to the original table wouldn&#8217;t automatically include those months in our results.</p><p>We also give the resulting table expression the alias <code>SalesRows</code>, which provides a name for the transformed data within the query. SQL Server requires an alias for the result of <code>UNPIVOT</code>, and we can reference it when qualifying columns in more involved queries. The final <code>ORDER BY</code> organizes the results by product identifier and month name, making it convenient to examine the records belonging to each product.</p><p>Notice that the <code>UNPIVOT</code> clause doesn&#8217;t require an aggregate function such as <code>SUM</code> or <code>AVG</code>. We&#8217;re moving existing column values into rows without calculating new amounts or combining measurements from different records.</p><p>This is an important distinction from <code>PIVOT</code>, which performs aggregation as part of its operation. With <code>UNPIVOT</code>, the amounts remain associated with their original product and year, while the month names become values that we can reference in later queries.</p><h4>Reading the Transformed Rows</h4><p>After executing our query, the original two records produce five rows containing individual monthly sales amounts. The output still includes the product identifiers and years, but the three monthly columns have been replaced with <code>SalesMonth</code> and <code>SalesAmount</code>.</p><p>For product <code>101</code>, we receive three records. January has a sales amount of <code>1250.00</code>, February contains <code>1400.00</code>, and March contains <code>1600.00</code>. Each record retains the product identifier <code>101</code> and the sales year <code>2026</code>, allowing us to identify where the amount originally came from.</p><p>Product <code>102</code> produces only two records, with January containing <code>900.00</code> and March containing <code>1150.00</code>. The February value was <code>NULL</code> in the original table, so it doesn&#8217;t produce a row during the transformation.</p><p>We can compare this result with the original wide table to see what changed. Previously, each product occupied a single row containing three monthly amounts. After applying <code>UNPIVOT</code>, each available monthly amount occupies its own row, accompanied by the month name and identifying information.</p><p>The relationship between the sales amounts and their products remains intact. We haven&#8217;t changed which product owns a particular value, and the year associated with each amount remains available. If our source table contained records for multiple years, those year values would continue to accompany the corresponding monthly amounts.</p><p>What changes is how we can reference the months in SQL. Instead of selecting different columns for different periods, we can refer to <code>SalesMonth</code> when we want to identify a particular month or <code>SalesAmount</code> when we want to access its numeric value.</p><p>The number of rows produced depends on how much information is present in the selected source columns. If a record contains values across five columns included in <code>UNPIVOT</code>, that record can produce five output rows. Missing values reduce that number because <code>NULL</code> entries are excluded.</p><p>This also means the transformed result can contain considerably more rows than the original table. For a source containing thousands of products and twelve monthly columns, the resulting row count could be substantially larger if most of those columns contain recorded amounts.</p><p>Keep in mind that a greater number of rows doesn&#8217;t mean the query has created additional sales measurements. We&#8217;re retrieving the existing values in a different arrangement, where each generated row corresponds to a particular source column from an original record.</p><p>The <code>ORDER BY</code> clause from our earlier query sorts <code>SalesMonth</code> alphabetically according to the applicable collation. As a result, February appears before January, followed by March. If we need chronological ordering for a report, we can provide an expression that assigns each month its calendar position rather than sorting the names directly.</p><p>It&#8217;s also important to recognize that <code>UNPIVOT</code> isn&#8217;t guaranteed to reverse an earlier <code>PIVOT</code> operation perfectly. The two operators transform data in opposite directions, but <code>PIVOT</code> can combine multiple source records through aggregation.</p><p>If several original values were combined into a total, applying <code>UNPIVOT</code> afterward would return that total as an individual value rather than recovering the records that contributed to it. The original information has already been aggregated, so those individual values aren&#8217;t available to reconstruct.</p><p>Missing values introduce a similar limitation because <code>UNPIVOT</code> excludes <code>NULL</code> entries from its output. This means we can&#8217;t assume that every source column will produce a corresponding row, which becomes important when reports depend on the number of recorded periods or measurements.</p><h4>Handling Missing Values</h4><p>Our sample table contains a <code>NULL</code> value for February under product <code>102</code>, which gives us an opportunity to examine how <code>UNPIVOT</code> handles missing information. SQL Server doesn&#8217;t produce an output row for a source column containing <code>NULL</code>, so the February record for that product is absent from the transformed result.</p><p>This behavior is part of the operator itself, and SQL Server doesn&#8217;t provide an <code>INCLUDE NULLS</code> option within its <code>UNPIVOT</code> syntax.</p><p>The distinction between <code>NULL</code> and zero is important when deciding how the resulting data should be interpreted. Zero represents an actual numeric value, while <code>NULL</code> indicates that the value is missing or unknown.</p><p>If a product recorded zero sales for February, that amount would still produce a row because zero is a valid numeric value. However, if the monthly column contains <code>NULL</code>, there is no corresponding output row. This difference can affect reports that count recorded periods, calculate averages, or identify products with incomplete sales information.</p><p>Depending on the information we&#8217;re retrieving, we may want to retain a row for every month, including those without recorded amounts. If the missing values should genuinely be treated as zero, we can substitute them before applying <code>UNPIVOT</code>.</p><p>We can do this through a derived table and the <code>ISNULL</code> function:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;472ee904-8371-4497-a580-ebc5a69ac559&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    ProductID,
    SalesYear,
    SalesMonth,
    SalesAmount
FROM
(
    SELECT
        ProductID,
        SalesYear,
        ISNULL(January, 0.00) AS January,
        ISNULL(February, 0.00) AS February,
        ISNULL(March, 0.00) AS March
    FROM dbo.MonthlySales
) AS SourceData
UNPIVOT
(
    SalesAmount FOR SalesMonth IN (January, February, March)
) AS SalesRows
ORDER BY ProductID, SalesMonth;</code></pre></div><p>We begin with an inner <code>SELECT</code> statement that retrieves the existing sales records and applies <code>ISNULL</code> to each monthly column. The function evaluates its first argument and returns that value when it isn&#8217;t <code>NULL</code>. If the value is missing, the function returns the replacement provided as its second argument.</p><p>For our query, we supply <code>0.00</code> as that replacement, so any missing monthly amount becomes zero in the derived table. Because the source columns are defined as <code>DECIMAL(12, 2)</code>, the <code>ISNULL</code> expressions retain that numeric definition.</p><p>The outer query then applies <code>UNPIVOT</code> to the results returned by <code>SourceData</code>. We haven&#8217;t updated the original table, and the replacement values exist only within the query.</p><p>Product <code>102</code> now produces a February record with a <code>SalesAmount</code> of <code>0.00</code>, giving both products three monthly records in the output. The other sales figures remain unchanged because <code>ISNULL</code> returns their existing amounts rather than replacing them.</p><p>We should only make this substitution when zero accurately represents the missing information. If February&#8217;s sales amount hasn&#8217;t been collected or is genuinely unknown, replacing it with zero would make the result appear as though no sales occurred during that period.</p><p>There are also situations where we want to retain missing values as <code>NULL</code> while still receiving a row for every month. SQL Server&#8217;s <code>CROSS APPLY</code> operator can accomplish this with a <code>VALUES</code> table constructor, providing an alternative to <code>UNPIVOT</code> when preserving those entries is necessary.</p><p>We can retrieve the same monthly measurements through the following query:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;b0dfbfaf-f15e-4dd4-bbf5-4957455616e6&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    s.ProductID,
    s.SalesYear,
    v.SalesMonth,
    v.SalesAmount
FROM dbo.MonthlySales AS s
CROSS APPLY
(
    VALUES
        ('January', s.January),
        ('February', s.February),
        ('March', s.March)
) AS v(SalesMonth, SalesAmount)
ORDER BY s.ProductID, v.SalesMonth;</code></pre></div><p>We begin by retrieving records from <code>MonthlySales</code> with the alias <code>s</code>. The <code>CROSS APPLY</code> clause then evaluates the <code>VALUES</code> expression for each source record, producing three month and amount pairs.</p><p>Each pair contains the month name as a text value followed by the corresponding amount from the source table. The alias <code>v(SalesMonth, SalesAmount)</code> names the two columns returned by that expression, allowing us to reference them in the outer <code>SELECT</code>.</p><p>Unlike <code>UNPIVOT</code>, this <code>VALUES</code> expression retains the February row for product <code>102</code> with its original <code>NULL</code> amount. We now receive six rows because every product contributes three monthly entries, including the missing measurement.</p><p>The <code>CROSS APPLY</code> query also preserves the original numeric values without replacing them. Product <code>101</code> retains its three recorded amounts, while product <code>102</code> still has <code>900.00</code> for January and <code>1150.00</code> for March. February remains <code>NULL</code>, allowing the resulting data to distinguish missing information from an actual zero.</p><p>This gives us an alternative when reports require every period to appear without assigning numeric values to unknown measurements. The original source remains unchanged, and we can choose the transformation according to how the missing information should be represented.</p><h4>Summarizing the Results</h4><p>After transforming our monthly columns into rows, we can apply familiar SQL operations to the resulting data. The new <code>SalesMonth</code> column allows us to group records by month, while <code>SalesAmount</code> provides the numeric values needed for calculations.</p><p>We can begin by calculating the combined sales amount for each month across all products. Rather than referencing January, February, and March individually in the calculation, we&#8217;ll group the transformed rows according to their month names:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;a585cfc7-19cb-42fa-b3b8-3e88b4c9ef10&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    SalesMonth,
    SUM(SalesAmount) AS TotalSales
FROM dbo.MonthlySales
UNPIVOT
(
    SalesAmount FOR SalesMonth IN (January, February, March)
) AS SalesRows
GROUP BY SalesMonth
ORDER BY SalesMonth;</code></pre></div><p>We first apply <code>UNPIVOT</code> to produce the individual monthly records, then use <code>GROUP BY SalesMonth</code> to collect records belonging to the same month. The <code>SUM</code> function adds the corresponding <code>SalesAmount</code> values within each group and returns the result as <code>TotalSales</code>.</p><p>With our sample data, January produces a total of <code>2150.00</code> from the amounts recorded for products <code>101</code> and <code>102</code>. February returns <code>1400.00</code> because only product <code>101</code> has a recorded value, while March returns <code>2750.00</code> from both products.</p><p>The original records contained three monthly columns, but our calculation now references only the <code>SalesAmount</code> field. We can apply the same aggregate function to every month included in the transformation without creating an individual expression for each source column. We should also pay attention to the identifying columns when calculating totals across different periods. Our table currently contains records for 2026, so grouping by month alone produces the expected totals for that year.</p><p>If the table contained records from multiple years, however, grouping only by <code>SalesMonth</code> would combine January amounts from every year into a single total. To calculate monthly totals for individual years, we would include <code>SalesYear</code> in both the <code>SELECT</code> and <code>GROUP BY</code> clauses.</p><p>Past aggregation, we can filter the transformed rows through a regular <code>WHERE</code> clause. Suppose we want to retrieve monthly sales amounts of at least <code>1000.00</code> from 2026, regardless of which original month column contained those values.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;d3dc28b0-d59c-4818-89d4-d1a256c9bfa3&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    ProductID,
    SalesMonth,
    SalesAmount
FROM dbo.MonthlySales
UNPIVOT
(
    SalesAmount FOR SalesMonth IN (January, February, March)
) AS SalesRows
WHERE SalesYear = 2026
    AND SalesAmount &gt;= 1000.00
ORDER BY ProductID, SalesMonth;</code></pre></div><p>We begin with the same transformation, but this time the <code>WHERE</code> clause restricts the rows returned by the query. The first condition selects records associated with 2026, while the second keeps only those with a monthly sales amount of at least <code>1000.00</code>.</p><p>For product <code>101</code>, all three months meet the minimum amount, so we receive January, February, and March. Product <code>102</code> contributes only March because its January amount is <code>900.00</code>, while February doesn&#8217;t produce a row due to its original <code>NULL</code> value.</p><p>The benefit of this format becomes apparent when we need to apply the same condition across multiple source columns. We can reference <code>SalesAmount</code> directly rather than creating conditions for every individual month, and the <code>SalesMonth</code> field tells us where each matching amount originated.</p><p>We can also incorporate these results into larger queries through joins, derived tables, or common table expressions. For instance, we could join the transformed records to a product table through <code>ProductID</code>, allowing us to retrieve product names or categories alongside their monthly sales amounts.</p><p>There are some performance considerations when transforming larger datasets. If a source table contains a substantial number of records and several monthly columns, <code>UNPIVOT</code> can produce far more rows than the original source. Subsequent grouping, sorting, and filtering operations may need to process those additional records.</p><p>SQL Server documentation also notes that repeatedly applying <code>PIVOT</code> and <code>UNPIVOT</code> within the same statement can negatively affect query performance. When queries involve multiple transformations, we can examine the execution plan to identify expensive operations and determine where unnecessary processing occurs.</p><p>For our monthly sales example, we should include only the source columns needed by the query. Transforming three months when a report requires only those three periods avoids processing additional monthly values that won&#8217;t contribute to the requested results.</p><p>We can apply the same SQL operations to the transformed records as we would to other query results, including filtering by product, calculating monthly totals, and retrieving values that meet particular conditions. The original table continues to store its sales figures across individual columns, while <code>UNPIVOT</code> provides the row-based representation needed for those calculations.</p><h3>Conclusion</h3><p>With <code>UNPIVOT</code>, we can turn selected columns into rows by defining where their original names and values should appear while retaining the identifying information from each record. We also need to account for how <code>NULL</code> values are excluded during the transformation, with options to handle missing data before retrieving the results. From there, we can filter, group, and calculate totals across the resulting rows without changing the original table.</p><ol><li><p><em><a href="iframe.php?url=https%3A%2F%2Flearn.microsoft.com%2Fen-us%2Fsql%2Ft-sql%2Fqueries%2Ffrom-using-pivot-and-unpivot%3Fview%3Dsql-server-ver17">FROM&#8202;&#8212;&#8202;PIVOT and UNPIVOT</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Flearn.microsoft.com%2Fen-us%2Fsql%2Ft-sql%2Fqueries%2Ffrom-transact-sql%3Fview%3Dsql-server-ver17">FROM Clause in SQL Server</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Flearn.microsoft.com%2Fen-us%2Fsql%2Ft-sql%2Ffunctions%2Fisnull-transact-sql%3Fview%3Dsql-server-ver17">ISNULL Function</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Flearn.microsoft.com%2Fen-us%2Fsql%2Ft-sql%2Fqueries%2Ftable-value-constructor-transact-sql%3Fview%3Dsql-server-ver17">Table Value Constructor</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Flearn.microsoft.com%2Fen-us%2Fsql%2Ft-sql%2Ffunctions%2Fcast-and-convert-transact-sql%3Fview%3Dsql-server-ver17">CAST and CONVERT Functions</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2F%3Futm_source%3Dsubstack%26utm_medium%3Demail%26utm_content%3Dshare%26action%3Dshare"><span>Share Alexander Obregon's Substack</span></a></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21GC4K%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fee83428c-cd8b-4b91-bdd5-756fd8f9b22f_306x306.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!GC4K!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fee83428c-cd8b-4b91-bdd5-756fd8f9b22f_306x306.png 424w, https://substackcdn.com/image/fetch/$s_!GC4K!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fee83428c-cd8b-4b91-bdd5-756fd8f9b22f_306x306.png 848w, https://substackcdn.com/image/fetch/$s_!GC4K!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fee83428c-cd8b-4b91-bdd5-756fd8f9b22f_306x306.png 1272w, https://substackcdn.com/image/fetch/$s_!GC4K!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fee83428c-cd8b-4b91-bdd5-756fd8f9b22f_306x306.png 1456w" sizes="auto, 100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21GC4K%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fee83428c-cd8b-4b91-bdd5-756fd8f9b22f_306x306.png" width="306" height="306" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/ee83428c-cd8b-4b91-bdd5-756fd8f9b22f_306x306.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:306,&quot;width&quot;:306,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!GC4K!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fee83428c-cd8b-4b91-bdd5-756fd8f9b22f_306x306.png 424w, https://substackcdn.com/image/fetch/$s_!GC4K!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fee83428c-cd8b-4b91-bdd5-756fd8f9b22f_306x306.png 848w, https://substackcdn.com/image/fetch/$s_!GC4K!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fee83428c-cd8b-4b91-bdd5-756fd8f9b22f_306x306.png 1272w, https://substackcdn.com/image/fetch/$s_!GC4K!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fee83428c-cd8b-4b91-bdd5-756fd8f9b22f_306x306.png 1456w" sizes="auto, 100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Ffreesvg.org%2Fmono-sql">Image Source</a></figcaption></figure></div>]]></content:encoded></item><item><title><![CDATA[How to Manage Spring Boot 4 Profiles for Different Environments]]></title><description><![CDATA[As a Spring Boot application moves through development, testing, staging, and production, its configuration usually needs to change along the way.]]></description><link>https://alexanderobregon.substack.com/p/how-to-manage-spring-boot-4-profiles</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/how-to-manage-spring-boot-4-profiles</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Wed, 21 Oct 2026 22:17:08 GMT</pubDate><enclosure url="iframe.php?url=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d7f122f-7a36-4b66-a679-bad48ccd7dae_480x480.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%219e_I%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fddf4fbf5-04f2-4d03-8281-5146545a0530_800x444.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!9e_I!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fddf4fbf5-04f2-4d03-8281-5146545a0530_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!9e_I!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fddf4fbf5-04f2-4d03-8281-5146545a0530_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!9e_I!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fddf4fbf5-04f2-4d03-8281-5146545a0530_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!9e_I!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fddf4fbf5-04f2-4d03-8281-5146545a0530_800x444.jpeg 1456w" sizes="100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%219e_I%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fddf4fbf5-04f2-4d03-8281-5146545a0530_800x444.jpeg" width="800" height="444" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/ddf4fbf5-04f2-4d03-8281-5146545a0530_800x444.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:444,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!9e_I!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fddf4fbf5-04f2-4d03-8281-5146545a0530_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!9e_I!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fddf4fbf5-04f2-4d03-8281-5146545a0530_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!9e_I!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fddf4fbf5-04f2-4d03-8281-5146545a0530_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!9e_I!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fddf4fbf5-04f2-4d03-8281-5146545a0530_800x444.jpeg 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Fspring.io%2Fprojects%2Fspring-boot">Image Source</a></figcaption></figure></div><p>As a Spring Boot application moves through development, testing, staging, and production, its configuration usually needs to change along the way. Development may call for detailed logging and a local database, while production typically requires different database connections, logging levels, and processing limits. Spring Boot profiles let us manage these differences by keeping common settings in one place and defining only the values that change between environments, rather than copying entire configuration files. We can also provide settings through environment variables, map related properties to Java objects with <code>@ConfigurationProperties</code>, and control which components Spring creates with <code>@Profile</code>. Spring Boot 4 supports these features, allowing each environment to receive the configuration it needs without changing the application&#8217;s Java code for every deployment.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fsubscribe%3F"><span>Subscribe now</span></a></p><h3>Organizing Environment Configuration</h3><p>Spring Boot reads configuration from several sources when an application starts, including property files, YAML files, environment variables, and command-line arguments. Profiles determine which environment-specific files participate in configuration loading, allowing development, testing, staging, and production to have their own settings while sharing common values. Rather than maintaining a complete configuration file for every environment, we can keep properties that rarely change in one location and define replacements only where they&#8217;re needed. Also, Spring Boot loads the appropriate files based on the active profile, so we can move the application between environments without making changes to its Java code.</p><h4>Keeping Shared Settings in One File</h4><p>Most Spring Boot applications have configuration values that remain the same regardless of where they&#8217;re running. Properties such as the application name, default logging level, or general processing settings don&#8217;t necessarily need to change between development and production. Keeping these values in a central file reduces repetition and gives every environment the same starting configuration.</p><p>Spring Boot automatically reads <code>application.properties</code> from <code>src/main/resources</code>, making it a natural location for properties shared across environments. When we package the application into an executable JAR file, this configuration is included with it and remains available wherever the application runs.</p><p>For our report-processing application, we can begin by adding the following properties to <code>application.properties</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;5f6058e7-6fbe-4819-843a-a53780cc7162&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">spring.application.name=report-service

app.reports.format=summary
app.reports.batch-size=50

logging.level.root=INFO</code></pre></div><p>The <code>spring.application.name</code> property identifies our application as <code>report-service</code>, while <code>logging.level.root</code> establishes <code>INFO</code> as the default logging level. Messages at the <code>INFO</code> level and higher are permitted by the root logger unless more specific logging configuration changes that behavior.</p><p>Meanwhile, the properties beginning with <code>app.reports</code> represent custom settings we&#8217;ve defined for our application. Spring Boot doesn&#8217;t automatically assign behavior to these properties, but it makes their values available for our Java classes to read. The <code>format</code> property has a default value of <code>summary</code>, and <code>batch-size</code> is set to <code>50</code>, giving us starting values for how our application processes reports.</p><p>Because these values are defined in the shared configuration, they remain available when any of our environment profiles become active. If development needs detailed reports, we can replace the format for that environment while leaving the batch size unchanged. Production could retain summary reports while increasing the number processed per batch, without requiring us to repeat every property from the original file.</p><p>This also gives us a central location for changes that should affect every environment. If we decide to change the default report format, we can update its shared property rather than edit the same setting across several environment files. Properties that already have appropriate defaults don&#8217;t need to appear again in the environment-specific files.</p><p>Spring Boot supports <code>application.yaml</code> as well, which represents configuration through nested YAML entries rather than individual property assignments. Both formats support shared configuration, although sticking with the same format throughout an application helps avoid confusion. If <code>.properties</code> and YAML files exist in the same location, Spring Boot gives the <code>.properties</code> format priority.</p><p>Sensitive credentials should generally remain outside the packaged configuration. Database passwords, access tokens, and similar values can be supplied through external configuration or a dedicated secrets service rather than being committed to source control. Shared property files are better suited to settings that can safely accompany the application across environments.</p><h4>Adding Profile-Specific Overrides</h4><p>Environment-specific configuration files follow a naming convention that Spring Boot recognizes automatically. The filename begins with <code>application-</code>, followed by the profile name and the configuration file extension. For a profile named <code>dev</code>, the corresponding properties file is <code>application-dev.properties</code>. Spring Boot doesn&#8217;t require particular names for environments, so <code>dev</code>, <code>test</code>, <code>staging</code>, and <code>prod</code> are names we choose for our application. What counts is that the profile name in the filename matches the profile activated during startup.</p><p>For our report application, we can organize the files inside <code>src/main/resources</code> like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;66017176-121d-4a31-bc1f-94dd7f8ceb91&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">src/
&#9492;&#9472;&#9472; main/
    &#9492;&#9472;&#9472; resources/
        &#9500;&#9472;&#9472; application.properties
        &#9500;&#9472;&#9472; application-dev.properties
        &#9500;&#9472;&#9472; application-test.properties
        &#9500;&#9472;&#9472; application-staging.properties
        &#9492;&#9472;&#9472; application-prod.properties</code></pre></div><p>All five files belong in the same resources directory, but they serve different purposes. The original <code>application.properties</code> provides common values, while the remaining files contain properties intended for their respective environments.</p><p>Spring Boot reads the shared configuration alongside the file associated with the active profile. If the same property exists in both files, the profile-specific value takes priority within that configuration location. Properties absent from the active profile&#8217;s file can still receive their values from <code>application.properties</code>, so there&#8217;s no need to repeat settings that haven&#8217;t changed.</p><p>We&#8217;ll start with development by creating <code>application-dev.properties</code> and adding the following settings:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;8f085488-6335-4a2b-a8e8-b7fef0c978be&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">app.reports.format=detailed
logging.level.com.example.reports=DEBUG</code></pre></div><p>The report format changes from <code>summary</code> to <code>detailed</code>, allowing development to receive a more comprehensive reporting format when our application code reads that property.</p><p>We&#8217;ve also introduced a more specific logging configuration for the <code>com.example.reports</code> package. Assuming our report-related classes belong to that package, setting its logging level to <code>DEBUG</code> allows more detailed messages from those classes during development.</p><p>The shared root logging level remains <code>INFO</code>, while the configured package and its descendants receive the more specific <code>DEBUG</code> setting. This lets us examine report-related activity in greater detail without changing the logging level throughout the entire application.</p><p>Notice that <code>app.reports.batch-size</code> doesn&#8217;t appear in the development file. Spring Boot still has access to the shared value of <code>50</code>, so repeating it would serve no purpose. We only needed to add the properties that differ from the defaults.</p><p>Testing can have different requirements, particularly when we want to process smaller batches of data. We can create <code>application-test.properties</code> with the following property.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;c3b86756-6e12-41e3-89b8-b6de5e57986f&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">app.reports.batch-size=5</code></pre></div><p>With the <code>test</code> profile active, the batch size becomes <code>5</code>, while the report format remains <code>summary</code> because we haven&#8217;t provided a replacement.</p><p>The smaller batch size can help keep report-processing tests focused on limited amounts of data, although Spring Boot doesn&#8217;t require testing environments to have smaller processing limits. We&#8217;re choosing a value that makes sense for our application&#8217;s testing needs.</p><p>It&#8217;s also important to distinguish the profile name from the actual testing process. Naming a file <code>application-test.properties</code> doesn&#8217;t automatically activate it whenever a test runs. The <code>test</code> profile still needs to be selected for Spring Boot to load that configuration, which gives us control over the settings applied during testing.</p><p>For staging, we&#8217;ll create <code>application-staging.properties</code> and provide a different batch size:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;8620d841-ac93-4d88-a32b-bf290473061d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">app.reports.batch-size=25</code></pre></div><p>Staging environments are commonly used to evaluate an application before releasing changes to production. Depending on the deployment, staging may have settings similar to production or smaller resource limits to accommodate its available infrastructure.</p><p>Our staging configuration keeps the shared <code>summary</code> format while changing the batch size to <code>25</code>. We can introduce other properties later if staging requires different service addresses, timeouts, or logging levels, without needing to copy the entire shared configuration.</p><p>Production receives its own configuration through <code>application-prod.properties</code>, where we&#8217;ll increase the batch size again:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;36303c7c-9ba7-4558-aa0f-d11c7b007f75&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">app.reports.batch-size=100</code></pre></div><p>With <code>prod</code> active, the application receives a batch size of <code>100</code> while retaining the shared report format and default logging configuration.</p><p>We now have four environments with different settings, but we haven&#8217;t duplicated the application name, report format, or logging properties unnecessarily. Every environment begins with the common configuration and receives replacements only for properties that need different values.</p><p>This becomes more valuable as the application grows. If we introduce settings for database connection pools, request timeouts, or external service addresses, we can keep common defaults in <code>application.properties</code> and place environment-specific values in their corresponding files. Changes intended for production won&#8217;t require editing development configuration, while changes shared across environments can remain in the central file.</p><p>Spring Boot also supports profile-specific YAML files, like <code>application-prod.yaml</code>, with the same general loading behavior.</p><p>For applications with relatively few environment differences, we have an alternative that avoids creating multiple physical configuration files. Spring Boot allows a properties file to contain multiple configuration documents, divided by <code>#---</code>. Individual documents can then be activated conditionally through <code>spring.config.activate.on-profile</code>.</p><p>We could represent the shared report settings and the development override inside a single <code>application.properties</code> file:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;64a6b890-5fcf-4e97-8c97-a133fa6c4b91&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">spring.application.name=report-service

app.reports.format=summary
app.reports.batch-size=50

#---
spring.config.activate.on-profile=dev
app.reports.format=detailed</code></pre></div><p>The first document contains properties available regardless of the active profile. The <code>#---</code> marker begins a new configuration document, and <code>spring.config.activate.on-profile=dev</code> tells Spring Boot that the properties following it belong to the <code>dev</code> profile.</p><p>If development is active, the report format becomes <code>detailed</code>. For other profiles, that conditional document doesn&#8217;t contribute its properties, leaving the shared value of <code>summary</code> available.</p><p>This produces the same development format override as our earlier <code>application-dev.properties</code> file, so we&#8217;d choose either method rather than define the same property in both locations.</p><p>YAML supports multiple configuration documents as well, although its document separator is <code>---</code> without the comment prefix.</p><p>For applications with several environments, individual profile-specific files give us dedicated locations for their settings. Projects with fewer differences may benefit from conditional documents in a single file instead. Both methods let us keep shared defaults while providing different values for the environments that need them.</p><h4>Activating the Right Profile</h4><p>Creating profile-specific configuration files gives Spring Boot the files it needs, but we still need to tell it which profile should be active when the application starts. The selected profile determines which environment-specific configuration participates in the startup process, allowing us to control the application&#8217;s settings without editing the files whenever we change environments.</p><p>Spring Boot provides the <code>spring.profiles.active</code> property for selecting active profiles. We can supply this value through shared configuration, an operating system environment variable, or a command-line argument.</p><p>During local development, passing the profile directly when starting the packaged application is a convenient option. Assuming our application is packaged as <code>app.jar</code>, we can start it with the development profile through the following command.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;bash&quot;,&quot;nodeId&quot;:&quot;f139f35e-d9c5-4b3a-a58f-cac4f1abdbdc&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-bash">java -jar app.jar --spring.profiles.active=dev</code></pre></div><p>The <code>java -jar app.jar</code> portion starts our packaged application, while <code>--spring.profiles.active=dev</code> tells Spring Boot to activate the <code>dev</code> profile before completing configuration loading.</p><p>Spring Boot then loads the common settings from <code>application.properties</code> and applies the properties defined in <code>application-dev.properties</code>.</p><p>For our report application, this gives us a <code>detailed</code> report format, a batch size of <code>50</code>, and <code>DEBUG</code> logging for the configured report package. We haven&#8217;t changed any Java classes or modified the shared property values to get those development settings.</p><p>The same application can be started with the staging profile by providing <code>--spring.profiles.active=staging</code> instead. Spring Boot then loads the staging configuration, giving our report processor a batch size of <code>25</code> while preserving the shared report format.</p><p>To start the application with the production configuration, we&#8217;ll change the argument to <code>prod</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;bash&quot;,&quot;nodeId&quot;:&quot;1f87c5f8-52db-4350-950d-cb20ad89084a&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-bash">java -jar app.jar --spring.profiles.active=prod</code></pre></div><p>Production now receives the batch size of <code>100</code> from <code>application-prod.properties</code>, while the shared report format remains <code>summary</code> and the root logging level stays at <code>INFO</code>.</p><p>The packaged JAR remains identical between these launches. We don&#8217;t need to modify our Java classes or rebuild the application to select a different environment. Changing the active profile is enough for Spring Boot to load the corresponding configuration files.</p><p>For deployments, specifying the active profile through an operating system environment variable is another common option. This lets the deployment environment provide its configuration without requiring us to include the profile name directly in the Java startup command.</p><p>Spring Boot recognizes <code>SPRING_PROFILES_ACTIVE</code> as the environment variable corresponding to <code>spring.profiles.active</code>.</p><p>In a Bash-compatible shell, we can set that variable before starting our application:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;bash&quot;,&quot;nodeId&quot;:&quot;0ef5bb02-baf6-411d-90bf-060a2ffb3288&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-bash">export SPRING_PROFILES_ACTIVE=prod
java -jar app.jar</code></pre></div><p>The <code>export</code> command assigns <code>prod</code> to the environment variable and makes it available to processes launched from that shell. When we start the application with <code>java -jar app.jar</code>, Spring Boot reads the variable and activates the production profile.</p><p>Deployment platforms can provide the same variable through their configuration facilities, allowing the appropriate profile to be selected without modifying the packaged application. We can give development, staging, and production their own profile values while deploying the same JAR file across those environments.</p><p>Spring Boot also permits <code>spring.profiles.active</code> inside the shared <code>application.properties</code> file. However, defining it externally gives each deployment control over its environment rather than permanently associating the packaged configuration with a particular profile.</p><p>If we start the application without specifying an active profile, Spring Boot falls back to a default profile named <code>default</code>. The shared <code>application.properties</code> file still loads normally, and Spring Boot also checks for <code>application-default.properties</code> if that file exists.</p><p>We can change the default profile through <code>spring.profiles.default</code>, although leaving it as <code>default</code> is sufficient for applications where deployment environments explicitly select their active profiles.</p><p>There is an important restriction on where these properties can be declared. Both <code>spring.profiles.active</code> and <code>spring.profiles.default</code> must be defined in non-profile-specific configuration sources. We cannot place them inside files such as <code>application-dev.properties</code> or documents activated through <code>spring.config.activate.on-profile</code>.</p><p>This keeps the selection of active profiles independent of the configuration documents that depend on those profiles being active.</p><p>Spring Boot also supports activating multiple profiles during the same application startup. We can provide their names as a comma-delimited value, which allows more than one profile-specific configuration file to participate in configuration loading.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;bash&quot;,&quot;nodeId&quot;:&quot;a736439d-e7b6-4e65-9f38-205fe43ee07d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-bash">java -jar app.jar --spring.profiles.active=dev,metrics</code></pre></div><p>Both profiles become active, so Spring Boot can load configuration associated with <code>dev</code> and <code>metrics</code> alongside the shared properties.</p><p>The <code>metrics</code> profile is an optional name we&#8217;ve chosen for this example. We could create an <code>application-metrics.properties</code> file containing monitoring-related settings and activate it alongside whichever environment requires those settings.</p><p>If multiple active profiles contain conflicting properties in profile-specific files at the same configuration location, Spring Boot applies a last-wins rule. With <code>dev,metrics</code>, a value from <code>application-metrics.properties</code> can replace the same property defined in <code>application-dev.properties</code>.</p><p>This gives us flexibility when certain configuration groups should apply alongside the main environment. Still, for development, testing, staging, and production, selecting a single environment profile generally keeps the configuration more organized and reduces the chance of conflicting values.</p><p>Spring Boot reports active profile information during startup, which can help us confirm that the intended environment has been selected. If our application starts with unexpected settings, checking the reported active profiles gives us a starting point before examining the individual configuration files.</p><p>With the appropriate profile active, we can keep shared settings in <code>application.properties</code> and let Spring Boot load the environment-specific values that belong to that deployment.</p><h3>Applying Configuration in Java</h3><p>Configuration files determine which property values are available in each environment, but our Java classes still need a way to access those values and respond to the active profile. Spring Boot provides annotations that let us register components conditionally, bind related properties to Java objects, and read the final configuration without manually opening property files. We can also control which source takes priority when the same property has been defined in multiple locations. These features allow us to keep environment differences in configuration while the application continues running the same Java code across development, testing, staging, and production.</p><h4>Selecting Beans with <code>@Profile</code></h4><p>Configuration files handle differences in property values, but some environments need different Spring components rather than different settings. Development may require a local implementation of a service, while production connects to an external provider. Spring gives us the <code>@Profile</code> annotation to control which beans are registered in the application context based on the active profiles.</p><p>Spring beans are objects managed by the Spring container, which creates them and makes them available to other components through dependency injection. Normally, Spring registers eligible beans when the application starts, but adding <code>@Profile</code> lets us restrict their registration to particular environments.</p><p>We can place <code>@Profile</code> on classes annotated with <code>@Component</code> or <code>@Configuration</code>, or apply it directly to individual <code>@Bean</code> methods. The placement determines how broadly the condition applies. Adding it to a configuration class restricts registration of that class and the beans declared within it, while placing it on a method restricts only the bean created by that method.</p><p>For our report application, we&#8217;ll create a configuration class that registers a development-specific startup message:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;09b1ae14-f2f3-4e85-b687-e6fe43cfd080&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.reports;

import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

@Configuration(proxyBeanMethods = false)
public class ProfileNoticeConfiguration {

    @Bean
    @Profile("dev")
    ApplicationRunner developmentNotice() {
        return args -&gt; System.out.println(
            "Development profile is active"
        );
    }
}</code></pre></div><p>We begin with <code>@Configuration</code>, which identifies <code>ProfileNoticeConfiguration</code> as a class containing Spring bean definitions. The <code>proxyBeanMethods = false</code> setting tells Spring that it doesn&#8217;t need to intercept calls between <code>@Bean</code> methods in this configuration class.</p><p>Inside the class, we&#8217;ve defined an <code>ApplicationRunner</code> bean through the <code>developmentNotice()</code> method. Spring Boot executes registered <code>ApplicationRunner</code> beans during startup after the application context has been initialized, making this a convenient place for actions that should happen as the application starts.</p><p>The <code>@Profile("dev")</code> annotation controls when Spring registers this particular bean. If we activate the <code>dev</code> profile, Spring creates the runner, and it prints the development message during startup.</p><p>Starting the application with <code>prod</code>, however, means the bean won&#8217;t be registered, so the message doesn&#8217;t appear. We don&#8217;t need to add conditional statements inside <code>developmentNotice()</code> because Spring determines if the bean belongs in the application context before creating it.</p><p>This becomes more practical when different environments require different implementations of the same service. We could register a local report delivery component for development and a production component that communicates with an external service. Other classes could depend on the same Java interface, with Spring injecting the implementation registered for the active profile.</p><p>When defining alternative implementations, we should arrange their profile conditions so Spring doesn&#8217;t register multiple competing beans for a dependency that expects a single implementation. Conflicting bean registrations can lead to dependency injection errors if Spring can&#8217;t determine which bean to inject.</p><p>The annotation also accepts multiple profile names. Writing <code>@Profile({"dev", "test"})</code> allows a component to be registered when either <code>dev</code> or <code>test</code> is active, which can help when both environments need the same supporting component.</p><p>Spring supports profile expressions as well, including negation through <code>!</code>. With <code>@Profile("!prod")</code>, the component is registered whenever the <code>prod</code> profile is not active. That includes startup with the default profile if <code>prod</code> hasn&#8217;t been activated.</p><p>We should keep in mind that <code>@Profile</code> controls bean registration rather than property values or access permissions. For settings such as report format, batch size, or logging level, configuration properties are generally sufficient. Conditional beans become appropriate when the components themselves need to differ between environments.</p><h4>Binding Settings with <code>@ConfigurationProperties</code></h4><p>Custom configuration properties become more convenient when we can access them through Java objects instead of retrieving individual values throughout the application. Spring Boot provides <code>@ConfigurationProperties</code> for mapping related properties into a class or record, giving us a central location to represent configuration that belongs to the same feature.</p><p>Our report application already defines <code>app.reports.format</code> and <code>app.reports.batch-size</code>, which control the report format and processing batch size. Rather than retrieving these properties individually whenever we need them, we can create a Java record that holds both values.</p><p>We&#8217;ll name the record <code>ReportProperties</code> and give it the same property prefix we&#8217;ve been defining in our configuration files:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;165306e7-d0e7-4678-843e-3626a664ad44&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.reports;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "app.reports")
public record ReportProperties(
    String format,
    int batchSize
) {
}</code></pre></div><p>The <code>@ConfigurationProperties</code> annotation tells Spring Boot that this record represents properties beginning with <code>app.reports</code>. We don&#8217;t include the individual property names in the annotation because Spring Boot matches them to the record components during configuration binding.</p><p>Our <code>format</code> component receives the value of <code>app.reports.format</code>, while <code>batchSize</code> receives the value of <code>app.reports.batch-size</code>.</p><p>Notice that the property name contains a hyphen, but the Java component follows camel case naming. Spring Boot supports this through relaxed binding, which allows property names written in different supported naming styles to map to Java fields or record components without requiring identical spelling.</p><p>Spring Boot also handles converting values into the declared Java types. The report format remains a <code>String</code>, while the batch size becomes an <code>int</code>. If we provide a value that cannot be converted into the required type, configuration binding can fail during application startup.</p><p>Records are a good choice for this type of configuration because their components are initialized through the constructor and can&#8217;t be reassigned afterward. We can read their values through generated accessor methods without writing getters, setters, or a constructor ourselves.</p><p>To make <code>ReportProperties</code> available through Spring&#8217;s dependency injection system, we need to register it as a bean. Spring Boot provides <code>@ConfigurationPropertiesScan</code>, which searches for eligible classes annotated with <code>@ConfigurationProperties</code>.</p><p>We can add this annotation to our main application class:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;ac032723-40b8-45e6-8387-54d12f86ab10&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.reports;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;

@SpringBootApplication
@ConfigurationPropertiesScan
public class ReportApplication {

    public static void main(String[] args) {
        SpringApplication.run(ReportApplication.class, args);
    }
}</code></pre></div><p>The <code>@SpringBootApplication</code> annotation identifies our main application class and includes the standard Spring Boot configuration and component scanning behavior.</p><p>By adding <code>@ConfigurationPropertiesScan</code>, we&#8217;re telling Spring Boot to search for configuration property classes beginning in the package containing <code>ReportApplication</code>.</p><p>Because our record belongs to <code>com.example.reports</code>, Spring Boot can discover it during startup and register it as a bean. If the record were located outside the scanned package hierarchy, we&#8217;d need to specify the appropriate package for scanning.</p><p>We also don&#8217;t need to annotate <code>ReportProperties</code> with <code>@Component</code>. Configuration property scanning handles its registration, allowing Spring Boot to create the record through constructor binding.</p><p>With the configuration record registered, we can inject it into a service that needs access to report settings.</p><p>For our application, we&#8217;ll create <code>ReportDescriptionService</code>, which reads the report format and batch size:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;0f87826c-674d-4235-9df3-f55a8ecc784f&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.reports;

import org.springframework.stereotype.Service;

@Service
public class ReportDescriptionService {

    private final ReportProperties properties;

    public ReportDescriptionService(ReportProperties properties) {
        this.properties = properties;
    }

    public String description() {
        return "Format = " + properties.format()
            + ", batch size = " + properties.batchSize();
    }
}</code></pre></div><p>The <code>@Service</code> annotation registers <code>ReportDescriptionService</code> as a Spring-managed component. We&#8217;ve declared a <code>ReportProperties</code> field and provided it through the constructor, allowing Spring to inject the configuration object when creating the service.</p><p>Inside <code>description()</code>, we call <code>properties.format()</code> and <code>properties.batchSize()</code> to retrieve the current configuration values. These accessor methods are generated automatically because <code>ReportProperties</code> is a Java record.</p><p>With the <code>dev</code> profile active, calling <code>description()</code> returns <code>Format = detailed, batch size = 50</code>, reflecting the development format override and the shared batch size.</p><p>If we run the application with <code>prod</code>, the same method returns <code>Format = summary, batch size = 100</code>.</p><p>We haven&#8217;t changed the service or created different versions of the record for each environment. Spring Boot resolves the active configuration before binding the properties, so our service receives the values associated with the current environment.</p><p>This becomes valuable when an application has several related configuration properties. We can group report settings into <code>ReportProperties</code>, create a different configuration class for database-related custom settings, and continue accessing those values through constructor injection.</p><p>Properties that must meet particular requirements should also be checked through configuration validation when appropriate. Spring Boot supports Jakarta Bean Validation for <code>@ConfigurationProperties</code> classes when the necessary validation dependencies and annotations are present. This allows invalid configuration to be rejected during startup rather than reaching a component later.</p><p>The record itself doesn&#8217;t automatically provide our report defaults. Those values come from the configuration files we&#8217;ve already defined, giving us a consistent location for managing differences between environments.</p><h4>Following Property Precedence</h4><p>Different configuration sources can define the same property, so Spring Boot needs a consistent way to determine which value the application receives. This is handled through property source precedence, where sources with higher priority can replace values from sources with lower priority.</p><p>We&#8217;ve already seen how an active profile can replace a value from <code>application.properties</code>. That behavior is part of a broader ordering system that includes external configuration files, operating system environment variables, Java system properties, and command-line arguments. For normal application startup, packaged <code>application.properties</code> values have lower priority than packaged profile-specific configuration values. External configuration files have higher priority than their packaged counterparts, with external profile-specific files taking priority over external shared files.</p><p>Operating system environment variables can then override values from those configuration files. Java system properties have higher priority than operating system environment variables, while command-line arguments take priority over these sources by default.</p><p>Spring Boot supports other property sources as well, including overrides intended for tests, but these are the sources we&#8217;ll encounter most frequently when configuring applications for different deployment environments.</p><p>Returning to our report application, the shared configuration provides a batch size of <code>50</code>, while the production profile replaces it with <code>100</code>.</p><p>Now, our production deployment may need a batch size of <code>150</code> without modifying the packaged configuration. We can provide that value through an operating system environment variable.</p><p>In a Bash-compatible shell, we&#8217;ll define it through the following command:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;bash&quot;,&quot;nodeId&quot;:&quot;f6fd89f7-84d9-4a81-91fd-19c795e536c3&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-bash">export APP_REPORTS_BATCHSIZE=150</code></pre></div><p>The variable <code>APP_REPORTS_BATCHSIZE</code> corresponds to our custom property <code>app.reports.batch-size</code>.</p><p>Spring Boot supports converting property names to their environment variable equivalents by replacing periods with underscores, removing hyphens, and converting letters to uppercase.</p><p>Following those rules, <code>app.reports.batch-size</code> becomes <code>APP_REPORTS_BATCHSIZE</code>, which Spring Boot can bind to our configuration record.</p><p>If the <code>prod</code> profile is active and this environment variable has been exported, the final batch size becomes <code>150</code>, replacing the value of <code>100</code> from <code>application-prod.properties</code>.</p><p>This allows deployment configuration to take priority over packaged defaults without requiring changes to the JAR file. We can provide different externally managed values depending on the environment where the application runs, while keeping the original configuration files unchanged.</p><p>Command-line arguments give us another way to override individual properties. Assuming the production profile has already been selected through the deployment environment, we can start the application with a different batch size.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;bash&quot;,&quot;nodeId&quot;:&quot;20021c95-da63-4ee2-bd84-829cbcdfac6c&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-bash">java -jar app.jar --app.reports.batch-size=200</code></pre></div><p>The <code>--app.reports.batch-size=200</code> argument tells Spring Boot to provide <code>200</code> for that property during startup.</p><p>Because command-line arguments have higher priority than operating system environment variables and configuration files, our <code>ReportProperties</code> record now receives a batch size of <code>200</code>.</p><p>The environment variable can still contain <code>150</code>, and <code>application-prod.properties</code> can still contain <code>100</code>, but neither takes priority over the command-line value in this situation.</p><p>This precedence behavior gives us several ways to control configuration depending on how the application is deployed. Packaged properties provide defaults, profile-specific files contain environment differences, and external sources can supply replacement values without rebuilding the application.</p><p>External configuration files also participate in the same system. Spring Boot searches supported locations outside the packaged application, including the current directory and its <code>config</code> directory.</p><p>For deployments requiring configuration files in other locations, we can provide <code>spring.config.additional-location</code> to include extra directories or files alongside Spring Boot&#8217;s default configuration locations. This differs from <code>spring.config.location</code>, which replaces the default search locations with the locations we specify.</p><p>The distinction becomes important when a deployment provides configuration through mounted files or directories. We can retain the packaged defaults while allowing properties supplied by the deployment environment to take priority where appropriate.</p><p>Sensitive values are another reason to keep configuration external. Database credentials and service tokens can be supplied through managed environment configuration or dedicated secrets services rather than being committed into repository files.</p><p>When a property receives an unexpected value, checking the different sources that define it can help identify the cause. The value in <code>application-prod.properties</code> may be correct, but an environment variable or command-line argument could be replacing it during startup.</p><p>Our Java classes don&#8217;t need special handling for those differences. Spring Boot resolves the property according to its precedence rules, and <code>@ConfigurationProperties</code> receives the final value during binding.</p><h4>Checking Spring Boot 4 Behavior</h4><p>The profile features we&#8217;ve been applying remain available in Spring Boot 4, including <code>@Profile</code>, <code>@ConfigurationProperties</code>, <code>@ConfigurationPropertiesScan</code>, and the existing profile-specific configuration conventions. We can continue organizing environment configuration through the same annotations and property files while following the current Spring Boot configuration system.</p><p>Spring Boot 4 builds on Spring Framework 7 and requires Java 17 or later. It also reorganizes several internal modules and updates configuration properties associated with particular technologies.</p><p>Some built-in property names changed during the transition from Spring Boot 3.x to Spring Boot 4. MongoDB configuration gives us a good example, where properties like <code>spring.data.mongodb.uri</code> and <code>spring.data.mongodb.host</code> have moved to <code>spring.mongodb.uri</code> and <code>spring.mongodb.host</code>.</p><p>Not every property beginning with <code>spring.data.mongodb</code> changed, however. Certain settings associated specifically with Spring Data MongoDB retain that prefix, including <code>spring.data.mongodb.auto-index-creation</code>.</p><p>For applications moving from Spring Boot 3.x, we should check the configuration properties associated with the dependencies our application relies on. The profile structure itself doesn&#8217;t need to change, but renamed configuration properties must be updated so Spring Boot can apply the intended settings.</p><p>Testing the configuration gives us a way to confirm that each environment receives the values we&#8217;ve defined, rather than relying entirely on what appears in the property files.</p><p>Spring Boot supports the <code>@ActiveProfiles</code> annotation through the Spring Test framework, allowing us to activate particular profiles while running tests.</p><p>For our report application, we&#8217;ll create a JUnit test that starts the Spring application context with the <code>test</code> profile active and checks the values bound to <code>ReportProperties</code>.</p><p>With <code>spring-boot-starter-test</code> available as a test dependency, we can create the following test with JUnit Jupiter and AssertJ:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;0e5dd491-4c27-45a9-9a9f-a88d879c9635&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.reports;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@ActiveProfiles("test")
class ReportPropertiesTests {

    @Autowired
    private ReportProperties properties;

    @Test
    void loadsTestProfileSettings() {
        assertThat(properties.format())
            .isEqualTo("summary");

        assertThat(properties.batchSize())
            .isEqualTo(5);
    }
}</code></pre></div><p>The <code>@SpringBootTest</code> annotation tells Spring Boot to create an application context for the test, allowing it to load the application&#8217;s configuration and register the Spring beans required for testing.</p><p>We&#8217;ve also added <code>@ActiveProfiles("test")</code>, which activates the <code>test</code> profile for this test class. Spring Boot therefore loads our testing configuration alongside the shared properties when preparing the application context.</p><p>The <code>@Autowired</code> annotation injects the <code>ReportProperties</code> bean into the test, giving us access to the values Spring Boot has already bound.</p><p>Inside <code>loadsTestProfileSettings()</code>, we check the report format and batch size through AssertJ assertions. The <code>assertThat()</code> method receives the actual property value, while <code>isEqualTo()</code> checks that it matches the value we expect.</p><p>The first assertion expects <code>summary</code>, which comes from our shared <code>application.properties</code> file because the testing profile doesn&#8217;t replace that setting.</p><p>Our second assertion expects <code>5</code>, matching the batch size provided by <code>application-test.properties</code>.</p><p>If either assertion receives an unexpected value, the test fails and identifies which expectation wasn&#8217;t met. Assuming no higher-priority property source overrides the values, both assertions pass with the configuration we&#8217;ve established.</p><p>We can follow the same testing method for other profiles when their configuration needs verification. Tests can check values directly through configuration property objects, which helps us confirm that the application receives the intended settings without manually examining every property file.</p><p>Spring Boot also provides configuration inspection facilities through its Actuator module. The <code>env</code> endpoint can provide information about environment properties and their sources, while <code>configprops</code> provides information about configuration properties bound to Spring beans.</p><p>These endpoints require Spring Boot Actuator and appropriate endpoint exposure settings. Access should be restricted because configuration information can contain sensitive details, and Spring Boot applies sanitization rules to endpoint responses.</p><p>For our report application, these facilities can help us investigate situations where the application receives a different value from the expected configuration. We can examine the active environment, identify the property source involved, and compare the resolved values with those received by <code>ReportProperties</code>.</p><p>By combining profile-aware tests with Spring Boot&#8217;s configuration inspection capabilities, we can verify the settings applied to each environment while keeping our Java components focused on reading and applying the configuration they receive.</p><h3>Conclusion</h3><p>Spring Boot profiles let us manage environment-specific settings while keeping common properties in <code>application.properties</code>. We can activate profiles at startup, override values through external configuration, and rely on property precedence to determine which settings take priority. With <code>@Profile</code>, we control which beans Spring registers, while <code>@ConfigurationProperties</code> binds the resolved values to Java objects. Profile-aware tests then help us verify that development, testing, staging, and production receive their intended configuration without changing the application&#8217;s Java code.</p><ol><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-boot%2Freference%2Ffeatures%2Fprofiles.html">Spring Boot Profiles</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-boot%2Freference%2Ffeatures%2Fexternal-config.html">Spring Boot Externalized Configuration</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-boot%2Freference%2Ffeatures%2Fexternal-config.html%23features.external-config.typesafe-configuration-properties">Type-Safe Configuration Properties</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-framework%2Freference%2Fcore%2Fbeans%2Fenvironment.html%23beans-definition-profiles">Spring Framework Bean Definition Profiles</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-boot%2Freference%2Ftesting%2Findex.html">Spring Boot Testing</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fgithub.com%2Fspring-projects%2Fspring-boot%2Fwiki%2FSpring-Boot-4.0-Migration-Guide">Spring Boot 4.0 Migration Guide</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2F%3Futm_source%3Dsubstack%26utm_medium%3Demail%26utm_content%3Dshare%26action%3Dshare"><span>Share Alexander Obregon's Substack</span></a></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21Yc0y%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fb20a6800-fe35-43e8-8c6d-d05acb4977c5_276x276.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!Yc0y!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb20a6800-fe35-43e8-8c6d-d05acb4977c5_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!Yc0y!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb20a6800-fe35-43e8-8c6d-d05acb4977c5_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!Yc0y!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb20a6800-fe35-43e8-8c6d-d05acb4977c5_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!Yc0y!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb20a6800-fe35-43e8-8c6d-d05acb4977c5_276x276.png 1456w" sizes="auto, 100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21Yc0y%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fb20a6800-fe35-43e8-8c6d-d05acb4977c5_276x276.png" width="276" height="276" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/b20a6800-fe35-43e8-8c6d-d05acb4977c5_276x276.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:276,&quot;width&quot;:276,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!Yc0y!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb20a6800-fe35-43e8-8c6d-d05acb4977c5_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!Yc0y!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb20a6800-fe35-43e8-8c6d-d05acb4977c5_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!Yc0y!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb20a6800-fe35-43e8-8c6d-d05acb4977c5_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!Yc0y!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb20a6800-fe35-43e8-8c6d-d05acb4977c5_276x276.png 1456w" sizes="auto, 100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Ficons8.com%2Ficon%2F90519%2Fspring-boot">Spring Boot</a> icon by <a href="iframe.php?url=https%3A%2F%2Ficons8.com%2F">Icons8</a></figcaption></figure></div>]]></content:encoded></item><item><title><![CDATA[Weekly Recap – October 18th, 2026]]></title><description><![CDATA[Spring Boot and SQL again!]]></description><link>https://alexanderobregon.substack.com/p/weekly-recap-october-18th-2026</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/weekly-recap-october-18th-2026</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Sun, 18 Oct 2026 12:53:16 GMT</pubDate><content:encoded><![CDATA[<p>I was able to post two articles this last week, covering file uploads in Spring Boot and pivot queries in SQL Server. The Spring Boot article goes through handling uploaded files, including multipart requests, file validation, and storage. The SQL Server article focuses on turning row values into columns with <code>PIVOT</code>, along with how to structure queries to get the results you need.</p><p>Spring Boot and SQL have been my most-read topics, and I&#8217;ve started looking into adding another series alongside them. I&#8217;ve posted articles covering quite a few different programming languages over the years, so I&#8217;m still deciding between going deeper into a language I&#8217;ve already covered or starting something entirely new. It&#8217;s still in the early planning stages, but I&#8217;m hoping to eventually introduce a third series and get closer to posting three articles a week on a relatively consistent basis.</p><p>If you&#8217;re new or want to know more about what I do, check out my <a href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fabout">About Me page.</a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fsubscribe%3F"><span>Subscribe now</span></a></p><h4><strong>Substack Articles</strong></h4><div class="digest-post-embed" data-attrs="{&quot;nodeId&quot;:&quot;10978b46-60b7-416e-abb7-d924961f26e1&quot;,&quot;caption&quot;:&quot;File uploads begin with MultipartFile, though handling them well goes past accepting bytes from an HTTP request. We need to set size limits before oversized requests reach controller code, account for several files in the same request, check incoming metadata, treat client-provided names carefully, and keep permanent storage outside the cont&#8230;&quot;,&quot;cta&quot;:null,&quot;showBylines&quot;:true,&quot;showDescription&quot;:true,&quot;showImage&quot;:true,&quot;size&quot;:&quot;sm&quot;,&quot;isEditorNode&quot;:true,&quot;title&quot;:&quot;How to Handle File Uploads in Spring Boot&quot;,&quot;publishedBylines&quot;:[{&quot;id&quot;:185785576,&quot;name&quot;:&quot;Alexander Obregon&quot;,&quot;bio&quot;:&quot;I publish on Medium and use Substack to share recaps, exclusive articles, and a way to support my programming-focused content.&quot;,&quot;photo_url&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8905d669-ef92-4534-9c0c-f486ad372c94_1905x1905.jpeg&quot;,&quot;is_guest&quot;:false,&quot;bestseller_tier&quot;:null}],&quot;post_date&quot;:&quot;2026-10-15T22:07:01.803Z&quot;,&quot;cover_image&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/2e6977bf-80a4-47f5-844f-aada29d98bf7_480x480.png&quot;,&quot;cover_image_alt&quot;:null,&quot;canonical_url&quot;:&quot;https://alexanderobregon.substack.com/p/how-to-handle-file-uploads-in-spring&quot;,&quot;section_name&quot;:&quot;Java and JVM&quot;,&quot;video_upload_id&quot;:null,&quot;id&quot;:220605841,&quot;type&quot;:&quot;newsletter&quot;,&quot;reaction_count&quot;:0,&quot;comment_count&quot;:0,&quot;publication_id&quot;:4719466,&quot;publication_name&quot;:&quot;Alexander Obregon's Substack&quot;,&quot;publication_logo_url&quot;:&quot;&quot;,&quot;belowTheFold&quot;:false,&quot;youtube_url&quot;:null,&quot;show_links&quot;:null,&quot;feed_url&quot;:null}"></div><div class="digest-post-embed" data-attrs="{&quot;nodeId&quot;:&quot;ab98817d-7692-4bf1-b146-c1a154e54f32&quot;,&quot;caption&quot;:&quot;Sales tables usually store each transaction on its own row, with details such as the region, sales channel, and amount recorded in columns. For reports that compare sales across different categories, those records need to be summarized and arranged so the totals can be read side by side. SQL Server provides the&quot;,&quot;cta&quot;:null,&quot;showBylines&quot;:true,&quot;showDescription&quot;:true,&quot;showImage&quot;:true,&quot;size&quot;:&quot;sm&quot;,&quot;isEditorNode&quot;:true,&quot;title&quot;:&quot;Pivot Queries with SQL Server&quot;,&quot;publishedBylines&quot;:[{&quot;id&quot;:185785576,&quot;name&quot;:&quot;Alexander Obregon&quot;,&quot;bio&quot;:&quot;I publish on Medium and use Substack to share recaps, exclusive articles, and a way to support my programming-focused content.&quot;,&quot;photo_url&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8905d669-ef92-4534-9c0c-f486ad372c94_1905x1905.jpeg&quot;,&quot;is_guest&quot;:false,&quot;bestseller_tier&quot;:null}],&quot;post_date&quot;:&quot;2026-10-16T16:48:34.519Z&quot;,&quot;cover_image&quot;:&quot;https://substackcdn.com/image/fetch/$s_!iw62!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcfb83ed9-5583-444b-92b0-ae5ac98a8de6_306x306.png&quot;,&quot;cover_image_alt&quot;:null,&quot;canonical_url&quot;:&quot;https://alexanderobregon.substack.com/p/pivot-queries-with-sql-server&quot;,&quot;section_name&quot;:&quot;SQL&quot;,&quot;video_upload_id&quot;:null,&quot;id&quot;:220610411,&quot;type&quot;:&quot;newsletter&quot;,&quot;reaction_count&quot;:0,&quot;comment_count&quot;:0,&quot;publication_id&quot;:4719466,&quot;publication_name&quot;:&quot;Alexander Obregon's Substack&quot;,&quot;publication_logo_url&quot;:&quot;&quot;,&quot;belowTheFold&quot;:false,&quot;youtube_url&quot;:null,&quot;show_links&quot;:null,&quot;feed_url&quot;:null}"></div><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2F%3Futm_source%3Dsubstack%26utm_medium%3Demail%26utm_content%3Dshare%26action%3Dshare"><span>Share Alexander Obregon's Substack</span></a></p>]]></content:encoded></item><item><title><![CDATA[Pivot Queries with SQL Server]]></title><description><![CDATA[Sales tables usually store each transaction on its own row, with details such as the region, sales channel, and amount recorded in columns.]]></description><link>https://alexanderobregon.substack.com/p/pivot-queries-with-sql-server</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/pivot-queries-with-sql-server</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Fri, 16 Oct 2026 16:48:34 GMT</pubDate><enclosure url="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21iw62%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fcfb83ed9-5583-444b-92b0-ae5ac98a8de6_306x306.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21DhVn%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F74f58915-b106-4539-93bf-0ac45a338e0e_800x373.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!DhVn!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F74f58915-b106-4539-93bf-0ac45a338e0e_800x373.png 424w, https://substackcdn.com/image/fetch/$s_!DhVn!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F74f58915-b106-4539-93bf-0ac45a338e0e_800x373.png 848w, https://substackcdn.com/image/fetch/$s_!DhVn!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F74f58915-b106-4539-93bf-0ac45a338e0e_800x373.png 1272w, https://substackcdn.com/image/fetch/$s_!DhVn!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F74f58915-b106-4539-93bf-0ac45a338e0e_800x373.png 1456w" sizes="100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21DhVn%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F74f58915-b106-4539-93bf-0ac45a338e0e_800x373.png" width="800" height="373" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/74f58915-b106-4539-93bf-0ac45a338e0e_800x373.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:373,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!DhVn!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F74f58915-b106-4539-93bf-0ac45a338e0e_800x373.png 424w, https://substackcdn.com/image/fetch/$s_!DhVn!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F74f58915-b106-4539-93bf-0ac45a338e0e_800x373.png 848w, https://substackcdn.com/image/fetch/$s_!DhVn!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F74f58915-b106-4539-93bf-0ac45a338e0e_800x373.png 1272w, https://substackcdn.com/image/fetch/$s_!DhVn!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F74f58915-b106-4539-93bf-0ac45a338e0e_800x373.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Fcommons.wikimedia.org%2Fwiki%2FFile%3ASql_data_base_with_logo.png">Image Source</a></figcaption></figure></div><p>Sales tables usually store each transaction on its own row, with details such as the region, sales channel, and amount recorded in columns. For reports that compare sales across different categories, those records need to be summarized and arranged so the totals can be read side by side. SQL Server provides the <code>PIVOT</code> operator to handle this transformation by turning selected category values into columns while calculating totals, counts, averages, or other aggregate results. The original table remains unchanged because the transformation happens within the query. To produce the expected results, we need to select the appropriate source columns, determine how SQL Server groups the records, and specify which categories should appear in the final report.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fsubscribe%3F"><span>Subscribe now</span></a></p><h3>How SQL Server PIVOT Works</h3><p>The <code>PIVOT</code> operator changes how query results are arranged by turning selected category values from rows into columns. Instead of displaying every transaction individually, we can group related records and calculate values for each category through an aggregate function. This is helpful for reports where information needs to be compared across regions, sales channels, or other categories. To see how the transformation happens, we need to identify the columns that determine the output rows, the category values that become column headings, and the numeric data that will be calculated.</p><h4>Starting with Sales Records</h4><p>Sales information is commonly stored with each row representing an individual transaction. Every record contains details about that sale, such as its identifier, the region where it occurred, the sales channel involved, and the amount recorded. As transactions accumulate, the same regions and channels appear repeatedly because every sale is stored independently.</p><p>We&#8217;ll begin by creating a temporary table containing sales from three regions and three sales channels. The table will include eight transactions, giving us enough records to see how the categories relate to each other.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;e12b7122-02a4-492a-a14e-bfbab79b07dd&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">DROP TABLE IF EXISTS #Sales;

CREATE TABLE #Sales (
    SaleId INT NOT NULL,
    Region VARCHAR(20) NOT NULL,
    SalesChannel VARCHAR(20) NOT NULL,
    Amount DECIMAL(10, 2) NOT NULL
);

INSERT INTO #Sales (SaleId, Region, SalesChannel, Amount)
VALUES
    (1, 'North', 'Online', 1200.00),
    (2, 'North', 'Online', 300.00),
    (3, 'North', 'Retail', 750.00),
    (4, 'South', 'Retail', 900.00),
    (5, 'South', 'Wholesale', 500.00),
    (6, 'West', 'Online', 600.00),
    (7, 'West', 'Wholesale', 1000.00),
    (8, 'West', 'Online', 150.00);</code></pre></div><p>We create <code>#Sales</code> as a temporary table, which SQL Server stores in <code>tempdb</code> and makes available within the current session. The table is automatically removed when the session ends, although we can also drop it manually. Including <code>DROP TABLE IF EXISTS</code> at the beginning allows us to execute the example again without encountering an error if the temporary table already exists.</p><p>Each column serves a particular purpose. <code>SaleId</code> identifies the transaction, while <code>Region</code> and <code>SalesChannel</code> tell us where the sale belongs. The <code>Amount</code> column stores the monetary value as <code>DECIMAL(10, 2)</code>, allowing up to ten total digits with two digits after the decimal point. This gives us a consistent numeric data type for the calculations we&#8217;ll perform later.</p><p>Looking at the inserted records, North appears three times because it contains three transactions. Two belong to the Online channel, while the remaining transaction belongs to Retail. West also contains multiple Online transactions, and South has sales recorded through Retail and Wholesale.</p><p>We can retrieve the sales information to examine how these records are currently arranged.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;25ce9c2d-269b-46b3-b2eb-3a96806e3010&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT Region, SalesChannel, Amount
FROM #Sales
ORDER BY Region, SalesChannel;</code></pre></div><p>We retrieve all eight transactions, sorted first by region and then by sales channel. Because we haven&#8217;t applied any aggregation, North&#8217;s two Online transactions remain on different rows, with amounts of 1,200 and 300. The query preserves each transaction&#8217;s individual amount while arranging the records in an order that&#8217;s convenient to read.</p><p>This structure makes sense for storing transaction details, but a regional sales report needs a different arrangement. If we want to compare how much each region earned through Online, Retail, and Wholesale sales, reading through individual transactions would require us to calculate the totals and compare the records manually.</p><p>Instead, we want the report to contain a single row for North, South, and West, with columns representing the three sales channels. Each region would then have its corresponding sales totals displayed beneath those headings, allowing us to compare the categories without reading through every transaction.</p><p>The source data already contains everything needed to produce that report. <code>Region</code> identifies which transactions belong to the same reporting group, <code>SalesChannel</code> determines their category, and <code>Amount</code> provides the values to be added.</p><p>We can perform that transformation through <code>PIVOT</code> without modifying the stored transactions. The operator takes the source records, applies an aggregate calculation, and returns the results arranged according to the selected categories. Our original sales remain available in the table, while the query produces a different presentation of the information.</p><p>Before constructing the complete query, we need to examine how the operator identifies these columns and determines what appears in the result.</p><h4>Reading the PIVOT Syntax</h4><p>SQL Server places the <code>PIVOT</code> operator within the <code>FROM</code> clause of a <code>SELECT</code> statement, where it operates on an existing table source. That source can be a table, a derived table created through a subquery, or another supported table expression.</p><p>For reporting queries, we can use a derived table to supply the exact columns needed for the transformation. This gives us control over the information passed to <code>PIVOT</code> before the aggregation takes place. Rather than passing every column from the original table, we can select only the columns that contribute to the intended report. The <code>PIVOT</code> syntax contains an aggregate function, a <code>FOR</code> clause, and an <code>IN</code> clause. These components tell SQL Server what calculation to perform, which source column contains the categories, and which category values should become output columns.</p><p>For our sales data, we can express the operator as follows:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;f987d492-e26c-421d-9b2c-835a1b1f8adb&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">PIVOT (
    SUM(Amount)
    FOR SalesChannel
    IN ([Online], [Retail], [Wholesale])
) AS PivotResult</code></pre></div><p>We begin with <code>SUM(Amount)</code>, which determines how the values will be calculated. SQL Server adds the amounts belonging to each matching group and sales channel, producing the totals that will populate the report. For North&#8217;s Online sales, this means adding the transactions of 1,200 and 300 to produce a total of 1,500.</p><p>The aggregate function doesn&#8217;t have to be <code>SUM()</code>, though the appropriate choice depends on what we want the report to calculate. <code>COUNT()</code> can count non-<code>NULL</code> values, <code>AVG()</code> calculates an average, and <code>MIN()</code> or <code>MAX()</code> retrieves the lowest or highest value within a group. Changing the aggregate function changes what the resulting numbers represent, while the categories can remain arranged in the same columns.</p><p>Following the aggregation, <code>FOR SalesChannel</code> identifies the source column whose values will become column headings. Our temporary table contains Online, Retail, and Wholesale within <code>SalesChannel</code>, so those values provide the categories for the transformation.</p><p>We then specify which categories should appear through the <code>IN</code> clause. By listing <code>[Online]</code>, <code>[Retail]</code>, and <code>[Wholesale]</code>, we&#8217;re requesting three output columns with those names. The values inside the brackets correspond to category values stored in the <code>SalesChannel</code> column, allowing SQL Server to associate the appropriate transactions with their respective output columns.</p><p>Square brackets are SQL Server&#8217;s syntax for delimiting identifiers. They allow names containing spaces or certain special characters to be referenced as identifiers, and they can also prevent conflicts with reserved words. Our category names don&#8217;t require brackets in every SQL context, but including them makes their intended use as column identifiers explicit.</p><p>The <code>IN</code> clause also determines which categories are included in the result. It doesn&#8217;t automatically create a new column for every distinct category found in the source data.</p><p>If a new sales channel named Direct is added to <code>#Sales</code>, an ordinary pivot query containing only the original three channels won&#8217;t include a Direct column. We would need to add <code>[Direct]</code> to the <code>IN</code> list before that category could appear in the report.</p><p>This behavior gives a regular <code>PIVOT</code> query a consistent set of output columns, which is helpful when preparing reports that expect particular column names every time they run.</p><p>Past the category columns, we also need to know how the operator determines which transactions contribute to each output row. SQL Server handles this through the source columns that remain after identifying the category column and the value being aggregated.</p><p>With our sales data, <code>SalesChannel</code> supplies the categories and <code>Amount</code> supplies the values for <code>SUM()</code>. If we provide only <code>Region</code>, <code>SalesChannel</code>, and <code>Amount</code> as the source, the remaining <code>Region</code> column determines how the records are grouped.</p><p>Transactions belonging to North are therefore calculated within the North group, while South and West receive their own groups. Within each region, SQL Server calculates the amount for every sales channel listed in the <code>IN</code> clause. We end up with a regional summary rather than individual transaction records because the region is the only remaining column identifying the output rows.</p><p>This grouping behavior is why we need to pay attention to the columns supplied to the operator. The source determines the level at which records are aggregated, so including columns beyond those needed for the report can change how the resulting rows are organized.</p><p>Finally, <code>AS PivotResult</code> assigns an alias to the pivoted table expression. SQL Server requires the result of the <code>PIVOT</code> operation to have a table alias, allowing the transformed data to be referenced by the surrounding query. The fragment we&#8217;ve examined belongs after its source within the <code>FROM</code> clause, rather than being an executable statement by itself.</p><p>Through these components, we can combine category-based aggregation with the creation of output columns. The original transaction records remain available in their stored format, while the query produces a different arrangement for reporting.</p><p>For our sales report, the three source columns contain enough information to produce regional totals across Online, Retail, and Wholesale sales. We identify <code>Region</code> as the grouping column, calculate the amounts through <code>SUM()</code>, and select the categories that should appear as columns through the <code>FOR</code> and <code>IN</code> clauses.</p><h3>Creating Pivot Reports</h3><p>With the sales data available and the <code>PIVOT</code> syntax established, we can now put those components into a complete query. The goal is to produce a report where each region appears on its own row, with the total sales amounts distributed across Online, Retail, and Wholesale columns. We&#8217;ll start with a fixed set of categories, then examine how to handle missing values and control the grouping before moving into dynamically generated columns.</p><h4>Building the First Pivot Query</h4><p>Our first report will calculate the total sales amount for each region, organized by sales channel. Rather than retrieving individual transactions, we want each region to appear on a single row so the totals can be compared across the three channels.</p><p>To accomplish this, we&#8217;ll select the columns needed for the calculation, apply <code>PIVOT</code> to aggregate the amounts, and retrieve the resulting columns in the order we want them displayed.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;a98e0451-4445-456c-aa5a-829a19109b8d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    Region,
    [Online],
    [Retail],
    [Wholesale]
FROM (
    SELECT Region, SalesChannel, Amount
    FROM #Sales
) AS SourceRows
PIVOT (
    SUM(Amount)
    FOR SalesChannel
    IN ([Online], [Retail], [Wholesale])
) AS PivotResult
ORDER BY Region;</code></pre></div><p>We begin with the inner <code>SELECT</code>, which retrieves <code>Region</code>, <code>SalesChannel</code>, and <code>Amount</code> from our temporary table. Those three columns contain everything needed to calculate the regional totals. The derived table receives the alias <code>SourceRows</code>, allowing its results to serve as the source for the <code>PIVOT</code> operator.</p><p>Next, <code>SUM(Amount)</code> calculates the combined sales amount for matching records. The <code>FOR SalesChannel</code> clause identifies the categories, while the <code>IN</code> list determines which sales channels will appear as output columns. Because <code>Region</code> is the only remaining source column, SQL Server groups the transactions by region.</p><p>Looking at North, we have two Online transactions worth 1,200 and 300, so their combined amount becomes 1,500 under the Online column. The Retail column contains 750 because North has a single retail transaction, while Wholesale has no matching records.</p><p>South produces 900 under Retail and 500 under Wholesale. West has two Online transactions totaling 750, along with a Wholesale transaction worth 1,000. These calculations give us three output rows, each representing the combined sales for its region.</p><p>The outer <code>SELECT</code> retrieves the region and the three pivoted columns. We can specify their order independently of how the categories appeared in the original table, which helps when preparing reports that need a consistent column arrangement. Finally, <code>ORDER BY Region</code> sorts the results alphabetically, placing North before South and West.</p><p>Notice that we haven&#8217;t included a <code>GROUP BY</code> clause. The <code>PIVOT</code> operator performs the aggregation according to the source columns and the selected categories. We identify the values through <code>SUM()</code>, and SQL Server calculates the totals for each combination of region and sales channel.</p><p>The result gives us the regional totals without changing the transactions stored in <code>#Sales</code>. We can still query those individual records whenever we need their original details, while the pivot query provides a summarized view for reporting.</p><p>This structure can also support other calculations. Replacing <code>SUM(Amount)</code> with <code>AVG(Amount)</code> would calculate the average transaction amount for each region and sales channel rather than their combined totals. The category columns would remain the same, but the numbers would represent a different measurement.</p><h4>Handling Missing Values</h4><p>Some regions don&#8217;t have transactions recorded through every sales channel. In our sample data, North has no Wholesale sales, South has no Online sales, and West has no Retail sales. The report still contains columns for all three channels because they&#8217;re explicitly included in the <code>PIVOT</code> clause.</p><p>For our <code>SUM(Amount)</code> calculation, SQL Server returns <code>NULL</code> when a region has no matching transactions for a listed category. This means North&#8217;s Wholesale column contains <code>NULL</code>, while its Online and Retail columns contain their calculated totals.</p><p>The distinction is important because <code>NULL</code> represents the absence of a value, while zero is an actual numeric value. In a sales report, we may prefer zero for missing combinations so that every region displays numeric amounts across all the channels.</p><p>We can handle this directly in the outer <code>SELECT</code> by applying <code>COALESCE()</code> to each pivoted column:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;d02ad258-cd8d-4fd6-ad4f-a84fc7f245f8&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    Region,
    COALESCE([Online], 0) AS Online,
    COALESCE([Retail], 0) AS Retail,
    COALESCE([Wholesale], 0) AS Wholesale
FROM (
    SELECT Region, SalesChannel, Amount
    FROM #Sales
) AS SourceRows
PIVOT (
    SUM(Amount)
    FOR SalesChannel
    IN ([Online], [Retail], [Wholesale])
) AS PivotResult
ORDER BY Region;</code></pre></div><p>We apply <code>COALESCE()</code> after the <code>PIVOT</code> operation has calculated the totals. The function examines its arguments from left to right and returns the first value that isn&#8217;t <code>NULL</code>. If a pivoted column contains an amount, that amount is returned. If the result is <code>NULL</code>, the function returns zero instead.</p><p>For North, the Wholesale column now displays <code>0</code>, while Online remains at 1,500 and Retail remains at 750. South displays <code>0</code> under Online, and West displays <code>0</code> under Retail. None of the existing sales amounts are changed by this adjustment.</p><p>The aliases following each <code>COALESCE()</code> expression give the output columns their intended names. Without those aliases, the result would contain expression-based column headings rather than the familiar Online, Retail, and Wholesale names.</p><p>We should also distinguish missing categories from transactions that actually contain an amount of zero. Those situations can produce the same displayed value after applying <code>COALESCE()</code>, but they represent different information. If the distinction is important to a report, preserving <code>NULL</code> may be the better choice.</p><p>In our case, <code>Amount</code> was declared <code>NOT NULL</code>, so every stored transaction contains a numeric value. The missing results come from region and sales channel combinations that have no matching transactions, rather than from missing amounts within existing records.</p><p>This adjustment affects only the query output. The original data remains unchanged, and <code>PIVOT</code> still performs the same aggregation before <code>COALESCE()</code> replaces the missing results. We can therefore decide how those values should be presented according to the requirements of the report without changing how the totals are calculated.</p><h4>Controlling the Grouping</h4><p>The columns we provide to <code>PIVOT</code> determine how SQL Server groups the source records, so selecting the appropriate columns is necessary for getting the expected totals. Earlier, we supplied only <code>Region</code>, <code>SalesChannel</code>, and <code>Amount</code>, which allowed the operator to calculate a single row per region. SQL Server identifies its grouping columns from the source data by excluding the column named in the <code>FOR</code> clause and the column being aggregated. Every remaining source column contributes to how the records are grouped, meaning the result can contain more rows if we include information that identifies individual transactions.</p><p>We can take a look at this behavior by adding <code>SaleId</code> to the source query while keeping the same aggregation and sales channel categories:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;674caa42-559f-4978-ab30-c503a32bdf09&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    SaleId,
    Region,
    [Online],
    [Retail],
    [Wholesale]
FROM (
    SELECT SaleId, Region, SalesChannel, Amount
    FROM #Sales
) AS SourceRows
PIVOT (
    SUM(Amount)
    FOR SalesChannel
    IN ([Online], [Retail], [Wholesale])
) AS PivotResult
ORDER BY Region, SaleId;</code></pre></div><p>The query executes successfully, but the results differ from our regional sales report. Because we included <code>SaleId</code> in the source, SQL Server groups records by both <code>SaleId</code> and <code>Region</code>. Every sale ID in our sample identifies an individual transaction, so the result contains eight rows instead of three.</p><p>We can examine North to see why this happens. Its Online transactions have sale IDs of 1 and 2, with amounts of 1,200 and 300. Rather than adding those amounts into a regional total of 1,500, the query returns each sale on its own row because the sale IDs differ.</p><p>The same behavior applies to the remaining transactions. Each result row contains the corresponding sale amount beneath its sales channel, while the remaining channels contain <code>NULL</code> because that particular sale belongs to only a single channel.</p><p>To restore the intended regional totals, we need to remove <code>SaleId</code> from the inner query and the outer <code>SELECT</code>. That returns us to the original three source columns, allowing SQL Server to group transactions solely by region.</p><p>This behavior also explains why retrieving every source column with <code>SELECT *</code> can cause unexpected results. If the table contains transaction identifiers, dates, customer information, or other fields, those columns can become part of the grouping calculation and produce more detailed output than the report requires.</p><p>There are situations where grouping by multiple columns is appropriate. If we wanted to compare sales by region for each calendar year, we could provide both <code>Region</code> and a year column in the source. SQL Server would then calculate totals for every distinct region and year combination, allowing the report to retain that extra level of detail.</p><p>The important decision is determining what each output row should represent before selecting the source columns. For regional totals, we need only the region as the grouping column. For more detailed reports, we can include other grouping columns that match the information we&#8217;re trying to retrieve.</p><p>We can also filter the source records before aggregation when a report needs data from a particular period or category. By applying a <code>WHERE</code> clause inside the derived table, we limit the transactions passed to <code>PIVOT</code>, so the resulting totals reflect only the records that meet those conditions.</p><p>Keeping the source focused on the required records and columns helps us control the level of detail in the report. The aggregation then follows the grouping established by that source, giving us totals that correspond to the categories and reporting periods we&#8217;ve selected.</p><h4>Generating Dynamic Columns</h4><p>Fixed pivot queries require us to specify the category values that will appear as output columns. Our earlier examples included Online, Retail, and Wholesale because those were the sales channels available in the sample data. In some reporting situations, the available categories can change as new records are added. Sales channels may be introduced or discontinued, and reports may need to reflect the values currently stored in the database without manually updating the column list.</p><p>SQL Server doesn&#8217;t automatically discover new output columns through a regular <code>PIVOT</code> statement. Instead, we can construct a dynamic SQL statement that retrieves the available category values, converts them into column identifiers, and includes those identifiers in the query.</p><p>We&#8217;ll do this by retrieving the distinct sales channels from <code>#Sales</code> and generating the column list before executing the completed pivot query:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;5e2f3782-7c97-4812-92f7-8c2b03912815&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">DECLARE @Columns NVARCHAR(MAX);
DECLARE @Sql NVARCHAR(MAX);

SELECT @Columns =
    STRING_AGG(
        CONVERT(NVARCHAR(MAX), QUOTENAME(SalesChannel)),
        N', '
    ) WITHIN GROUP (ORDER BY SalesChannel)
FROM (
    SELECT DISTINCT SalesChannel
    FROM #Sales
) AS Channels;

SET @Sql = N'
SELECT Region, ' + @Columns + N'
FROM (
    SELECT Region, SalesChannel, Amount
    FROM #Sales
) AS SourceRows
PIVOT (
    SUM(Amount)
    FOR SalesChannel IN (' + @Columns + N')
) AS PivotResult
ORDER BY Region;';

EXEC sys.sp_executesql @Sql;</code></pre></div><p>We begin by declaring two variables, <code>@Columns</code> and <code>@Sql</code>, both with the <code>NVARCHAR(MAX)</code> data type. The first will store the generated column names, while the second will contain the complete SQL statement that we want to execute.</p><p>Next, we retrieve the distinct values from <code>SalesChannel</code>. The <code>SELECT DISTINCT</code> statement prevents repeated categories from appearing in the generated list, which is necessary because the original table contains multiple transactions through the same sales channels.</p><p>We then pass each category value through <code>QUOTENAME()</code>. This function returns the value enclosed in delimiters, which are square brackets by default, so the resulting text can be referenced as a SQL Server identifier. It also handles closing brackets within identifier names. <code>QUOTENAME()</code> accepts identifiers up to 128 characters, which is sufficient for the <code>VARCHAR(20)</code> sales channels in our table.</p><p>The <code>CONVERT(NVARCHAR(MAX), QUOTENAME(SalesChannel))</code> expression gives <code>STRING_AGG()</code> a large string type for constructing the column list. This prevents the aggregate result from being restricted to the smaller maximum length associated with a regular <code>NVARCHAR</code> expression.</p><p>After converting the category names, <code>STRING_AGG()</code> combines them into a comma-delimited string. The <code>WITHIN GROUP (ORDER BY SalesChannel)</code> clause determines their order, allowing us to produce a consistent column sequence from the available categories.</p><p>With our original sales data, the generated value stored in <code>@Columns</code> is <code>[Online], [Retail], [Wholesale]</code>.</p><p>We then construct the SQL statement by placing that value into both the outer <code>SELECT</code> and the <code>IN</code> clause of <code>PIVOT</code>. This gives the query the same general structure as our earlier static version, except the category columns now come from the values retrieved from the table.</p><p>Finally, we execute the generated statement through <code>sys.sp_executesql</code>. This system stored procedure executes the SQL text stored in <code>@Sql</code>, returning the regional sales totals across the generated columns. The dynamic statement can also access our existing <code>#Sales</code> temporary table because it executes within the same session.</p><p>To see how the report responds to a new category, we can add a transaction through a sales channel that didn&#8217;t appear in the original dataset:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;0bb98107-0c48-43a3-a979-fa2faddf4076&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">INSERT INTO #Sales (SaleId, Region, SalesChannel, Amount)
VALUES (9, 'South', 'Direct', 250.00);</code></pre></div><p>We now have a ninth transaction belonging to South, recorded through a sales channel called Direct. The original three categories remain in the table, but the available category values have increased from three to four.</p><p>If we execute the dynamic pivot query again, the <code>SELECT DISTINCT</code> portion retrieves Direct alongside Online, Retail, and Wholesale. The generated column list now includes <code>[Direct]</code>, and the resulting report contains a corresponding column without requiring us to manually edit the <code>PIVOT</code> statement.</p><p>South will have 250 under Direct, while North and West will have <code>NULL</code> in that column because neither region has Direct transactions. The remaining sales totals stay the same as before.</p><p>Dynamic SQL allows us to accommodate changing categories, but it also introduces considerations for how the results are consumed. The number of columns and their names can differ between executions, so applications or reporting tools that expect a fixed result structure may need a consistent category list instead.</p><p>We also need to handle dynamically generated SQL carefully. Column identifiers cannot be supplied as ordinary parameters to <code>sp_executesql</code>, which is why we construct the category list as SQL text. Applying <code>QUOTENAME()</code> to the retrieved identifiers handles their delimiters and escaped brackets, while externally supplied filter values should be passed through the parameter support provided by <code>sp_executesql</code> rather than inserted directly into the statement text.</p><p>Performance remains relevant when the source table contains a large volume of transactions. The <code>PIVOT</code> operator performs aggregation over the rows supplied to it, so applying appropriate filters beforehand can reduce the records involved in the calculation. Repeated <code>PIVOT</code> operations within the same SQL statement can also increase processing costs.</p><p>For reports with established categories, the static query provides a predictable result with less SQL text to manage. When categories can change and the output needs to reflect the values currently stored in the database, generating the <code>IN</code> list dynamically allows us to build the report from those values while retaining the same aggregation behavior.</p><h3>Conclusion</h3><p>SQL Server&#8217;s <code>PIVOT</code> operator turns category values into report columns by grouping records and calculating aggregate results. We control how the data is arranged through the source columns, the aggregate function, and the <code>FOR</code> and <code>IN</code> clauses. Missing values can be handled with <code>COALESCE()</code>, while dynamic SQL allows the column list to adapt as categories change. With these components, we can produce reports that organize totals across categories without modifying the original data.</p><ol><li><p><em><a href="iframe.php?url=https%3A%2F%2Flearn.microsoft.com%2Fen-us%2Fsql%2Ft-sql%2Fqueries%2Ffrom-using-pivot-and-unpivot%3Fview%3Dsql-server-ver17">FROM Clause with PIVOT and UNPIVOT</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Flearn.microsoft.com%2Fen-us%2Fsql%2Ft-sql%2Ffunctions%2Fstring-agg-transact-sql%3Fview%3Dsql-server-ver17">STRING_AGG (Transact-SQL)</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Flearn.microsoft.com%2Fen-us%2Fsql%2Ft-sql%2Ffunctions%2Fquotename-transact-sql%3Fview%3Dsql-server-ver17">QUOTENAME (Transact-SQL)</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Flearn.microsoft.com%2Fen-us%2Fsql%2Ft-sql%2Flanguage-elements%2Fcoalesce-transact-sql%3Fview%3Dsql-server-ver17">COALESCE (Transact-SQL)</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2F%3Futm_source%3Dsubstack%26utm_medium%3Demail%26utm_content%3Dshare%26action%3Dshare"><span>Share Alexander Obregon's Substack</span></a></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21iw62%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fcfb83ed9-5583-444b-92b0-ae5ac98a8de6_306x306.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!iw62!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcfb83ed9-5583-444b-92b0-ae5ac98a8de6_306x306.png 424w, https://substackcdn.com/image/fetch/$s_!iw62!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcfb83ed9-5583-444b-92b0-ae5ac98a8de6_306x306.png 848w, https://substackcdn.com/image/fetch/$s_!iw62!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcfb83ed9-5583-444b-92b0-ae5ac98a8de6_306x306.png 1272w, https://substackcdn.com/image/fetch/$s_!iw62!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcfb83ed9-5583-444b-92b0-ae5ac98a8de6_306x306.png 1456w" sizes="auto, 100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21iw62%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fcfb83ed9-5583-444b-92b0-ae5ac98a8de6_306x306.png" width="306" height="306" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/cfb83ed9-5583-444b-92b0-ae5ac98a8de6_306x306.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:306,&quot;width&quot;:306,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!iw62!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcfb83ed9-5583-444b-92b0-ae5ac98a8de6_306x306.png 424w, https://substackcdn.com/image/fetch/$s_!iw62!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcfb83ed9-5583-444b-92b0-ae5ac98a8de6_306x306.png 848w, https://substackcdn.com/image/fetch/$s_!iw62!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcfb83ed9-5583-444b-92b0-ae5ac98a8de6_306x306.png 1272w, https://substackcdn.com/image/fetch/$s_!iw62!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcfb83ed9-5583-444b-92b0-ae5ac98a8de6_306x306.png 1456w" sizes="auto, 100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Ffreesvg.org%2Fmono-sql">Image Source</a></figcaption></figure></div>]]></content:encoded></item><item><title><![CDATA[How to Handle File Uploads in Spring Boot]]></title><description><![CDATA[File uploads begin with MultipartFile, though handling them well goes past accepting bytes from an HTTP request.]]></description><link>https://alexanderobregon.substack.com/p/how-to-handle-file-uploads-in-spring</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/how-to-handle-file-uploads-in-spring</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Thu, 15 Oct 2026 22:07:01 GMT</pubDate><enclosure url="iframe.php?url=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2e6977bf-80a4-47f5-844f-aada29d98bf7_480x480.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21BA8s%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fd525a1be-b79a-405e-8b06-24d771f5353a_800x444.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!BA8s!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd525a1be-b79a-405e-8b06-24d771f5353a_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!BA8s!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd525a1be-b79a-405e-8b06-24d771f5353a_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!BA8s!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd525a1be-b79a-405e-8b06-24d771f5353a_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!BA8s!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd525a1be-b79a-405e-8b06-24d771f5353a_800x444.jpeg 1456w" sizes="100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21BA8s%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fd525a1be-b79a-405e-8b06-24d771f5353a_800x444.jpeg" width="800" height="444" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/d525a1be-b79a-405e-8b06-24d771f5353a_800x444.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:444,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!BA8s!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd525a1be-b79a-405e-8b06-24d771f5353a_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!BA8s!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd525a1be-b79a-405e-8b06-24d771f5353a_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!BA8s!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd525a1be-b79a-405e-8b06-24d771f5353a_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!BA8s!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd525a1be-b79a-405e-8b06-24d771f5353a_800x444.jpeg 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Fspring.io%2Fprojects%2Fspring-boot">Image Source</a></figcaption></figure></div><p>File uploads begin with <code>MultipartFile</code>, though handling them well goes past accepting bytes from an HTTP request. We need to set size limits before oversized requests reach controller code, account for several files in the same request, check incoming metadata, treat client-provided names carefully, and keep permanent storage outside the controller. Spring Boot already configures multipart support for Spring MVC and exposes the main limits through application properties, while Spring Framework provides <code>MultipartFile</code> for reading uploaded content. From there, we can build a well-organized flow where the controller receives the request, validation checks the file, a storage service saves it, and error handling deals with failures in a consistent way.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fsubscribe%3F"><span>Subscribe now</span></a></p><h3>Receiving Files in Spring Boot</h3><p>Spring MVC can bind parts from a <code>multipart/form-data</code> request directly to <code>MultipartFile</code>, giving the controller access to uploaded content without manually parsing multipart boundaries. Spring Boot configures servlet multipart handling for a typical Spring MVC application, so receiving a file normally begins with a controller parameter rather than custom multipart infrastructure. At this stage, we can focus on how files enter the application, how large those requests are allowed to be, and how several files can arrive through the same endpoint.</p><h4>Receive a MultipartFile</h4><p>To receive a single uploaded file, we can declare <code>MultipartFile</code> as a controller parameter and connect it to a named multipart field through <code>@RequestParam</code>. The field name sent by the client needs to match the annotation value so Spring MVC can associate that part with the correct parameter:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;cd9c0e2b-fe20-4328-b9f7-94b2bb0efd18&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.uploads;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
public class FileUploadController {

    @PostMapping("/files")
    public ResponseEntity&lt;String&gt; upload(
            @RequestParam("file") MultipartFile file) {

        if (file.isEmpty()) {
            return ResponseEntity.badRequest()
                    .body("File cannot be empty");
        }

        return ResponseEntity.ok(
                "Received " + file.getSize() + " bytes"
        );
    }
}</code></pre></div><p>The multipart field named <code>file</code> becomes the <code>MultipartFile</code> passed into <code>upload</code>. Calling <code>isEmpty()</code> tells us that the submitted part contains no file data, while <code>getSize()</code> reports its size in bytes. <code>MultipartFile</code> also gives us methods such as <code>getContentType()</code>, <code>getOriginalFilename()</code>, <code>getInputStream()</code>, <code>getBytes()</code>, and <code>transferTo(Path)</code>, so later stages can inspect or copy the received content in the form that makes sense for the storage flow.</p><p>Spring treats uploaded content as request data rather than permanent storage. Depending on multipart configuration and the servlet container, content can remain in memory or move into temporary disk storage while the request is being handled. Anything the application needs after request handling finishes has to be copied to its intended destination later, which means <code>MultipartFile</code> represents the incoming upload rather than the final stored file.</p><p>The value inside <code>@RequestParam</code> also tells us what the client must send. With <code>@RequestParam("file")</code>, the multipart request needs a field named <code>file</code>. Spring MVC resolves that part before the controller body runs, allowing the method to receive a <code>MultipartFile</code> instead of dealing with raw multipart boundaries from the HTTP request.</p><p>Different <code>MultipartFile</code> methods serve different needs. <code>getBytes()</code> returns the entire upload as a byte array, while <code>getInputStream()</code> gives us a stream for reading the content. Basic information such as emptiness and byte count does not require reading the full file into memory first, so <code>isEmpty()</code> and <code>getSize()</code> are better choices when we only need information about the incoming part.</p><h4>Configure Upload Limits</h4><p>Size boundaries belong in multipart configuration so Spring Boot and the servlet container can reject requests that exceed the allowed limits before regular controller handling continues. Spring Boot provides <code>spring.servlet.multipart.max-file-size</code> for an individual file and <code>spring.servlet.multipart.max-request-size</code> for the complete multipart request.</p><p>For a project that accepts files up to 10 MB while allowing up to 25 MB across the entire request, <code>application.properties</code> can contain:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;toml&quot;,&quot;nodeId&quot;:&quot;9ad2d346-2b6a-43cb-8d2c-e582ffbd8ddc&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-toml">spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=25MB</code></pre></div><p>The first property limits every uploaded file independently. The second applies to the complete multipart request, including all file parts and the rest of the multipart content. This means a request can remain below the individual file limit and still exceed the total request limit when several files arrive at the same time.</p><p>Spring Boot defaults to <code>1MB</code> for <code>max-file-size</code> and <code>10MB</code> for <code>max-request-size</code>. Smaller uploads can stay below those values during early development, while larger files expose the limits quickly. Keeping the intended values in configuration makes the accepted request sizes part of the application settings rather than leaving the only size check inside controller code.</p><p>Multipart configuration also includes <code>spring.servlet.multipart.file-size-threshold</code>, which defaults to <code>0B</code> and controls the threshold after which file content is written to disk during multipart handling. <code>spring.servlet.multipart.location</code> can provide an intermediate directory when the project needs a particular temporary location. These settings deal with request handling rather than the permanent destination where an accepted upload will eventually be stored.</p><p>Spring Boot keeps multipart support active by default through <code>spring.servlet.multipart.enabled</code>. For a regular Spring MVC application, we do not need to register an extra multipart library merely to receive <code>MultipartFile</code> parameters because Spring Boot configures multipart support around the servlet facilities already available to the application.</p><p>The individual file limit and total request limit should match what the endpoint is expected to receive. An endpoint accepting a single profile image can keep the total request limit relatively close to its per-file limit, while a batch endpoint needs enough request capacity for several accepted files along with multipart overhead.</p><h4>Accept Multiple Files</h4><p>Several uploads can arrive through the same multipart field name, and Spring MVC can bind them to <code>List&lt;MultipartFile&gt;</code>. This gives us a single controller parameter for a batch of related files rather than requiring a different method parameter for every file that could arrive.</p><p>We can receive a batch through a multipart field named <code>files</code> like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;8556f910-1cab-42c3-ab44-334dbd78b5ea&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.uploads;

import java.util.List;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
public class BatchUploadController {

    @PostMapping("/files/batch")
    public ResponseEntity&lt;String&gt; uploadFiles(
            @RequestParam("files") List&lt;MultipartFile&gt; files) {

        long nonEmptyFiles = files.stream()
                .filter(file -&gt; !file.isEmpty())
                .count();

        return ResponseEntity.ok(
                "Received " + nonEmptyFiles + " non-empty files"
        );
    }
}</code></pre></div><p>Spring MVC collects multipart parts named <code>files</code> and places them into the list passed to <code>uploadFiles</code>. We then receive every submitted <code>MultipartFile</code> in the collection, while the <code>isEmpty()</code> check prevents an empty part from being counted as actual file content.</p><p>Clients send several files by repeating the same multipart field name. From a terminal, the request can be sent like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;bash&quot;,&quot;nodeId&quot;:&quot;cc93155a-5adb-4502-bebc-0e59c4fba2c8&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-bash">curl -X POST http://localhost:8080/files/batch \
  -F "files=@report.pdf" \
  -F "files=@photo.png"</code></pre></div><p>Both <code>-F</code> entries carry the field name <code>files</code>, which matches <code>@RequestParam("files")</code> in the controller. Spring MVC collects those parts before <code>uploadFiles</code> runs, so our controller receives the collection without parsing repeated multipart fields itself.</p><p>The total multipart request limit still applies across the entire batch. With a 10 MB <code>max-file-size</code> and a 25 MB <code>max-request-size</code>, three files close to 10 MB each can stay below the individual file boundary while exceeding the total request boundary. Batch endpoints therefore need both limits selected around the amount of data they are expected to receive.</p><p>Spring MVC can also bind uploaded files into <code>Map&lt;String, MultipartFile&gt;</code> or <code>MultiValueMap&lt;String, MultipartFile&gt;</code> when files arrive under different multipart field names. <code>List&lt;MultipartFile&gt;</code> remains a natural choice when several uploads belong to the same field, while the map forms are better suited to requests where different field names represent different file inputs.</p><h3>Building the Storage Flow</h3><p>Receiving multipart data only gets the file into the application. Before anything is kept permanently, the upload should pass application rules, receive a server-generated storage name, and move through a dedicated service that owns filesystem operations. Keeping those responsibilities away from the controller gives the HTTP layer a narrower job, while validation and storage behavior can change without turning every upload endpoint into a large method.</p><h4>Check the Uploaded Data</h4><p>Before a file reaches permanent storage, we can reject content that does not meet the rules for the endpoint. Spring Boot multipart limits already place an outer boundary around incoming requests, while application validation can apply more specific rules. An image endpoint, for instance, can accept a smaller size than the global multipart limit and restrict the declared media types to JPEG and PNG.</p><p>We can place those rules inside a dedicated component:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;c6444a55-4b4e-4ce1-ae60-89e5aea3477c&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.uploads;

import java.util.Set;

import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

@Component
public class ImageUploadValidator {

    private static final long MAX_SIZE = 5 * 1024 * 1024;

    private static final Set&lt;String&gt; ALLOWED_TYPES =
            Set.of("image/jpeg", "image/png");

    public void validate(MultipartFile file) {
        if (file.isEmpty()) {
            throw new InvalidUploadException("File cannot be empty");
        }

        if (file.getSize() &gt; MAX_SIZE) {
            throw new InvalidUploadException("File is too large");
        }

        String contentType = file.getContentType();

        if (contentType == null || !ALLOWED_TYPES.contains(contentType)) {
            throw new InvalidUploadException("File type is not accepted");
        }
    }
}</code></pre></div><p>The first check rejects a multipart part with no file data, while the second places a 5 MB application rule around this particular upload. That value can be lower than the global Spring Boot multipart limit because the two limits apply at different stages. The final condition accepts only the declared media types allowed by this endpoint.</p><p><code>getContentType()</code> returns the media type supplied with the multipart file. Because request metadata comes from the client, matching that value should not be treated as proof that the bytes truly belong to the claimed format. If the application needs file-format verification, the uploaded content can be read by a parser or detection library that recognizes the expected format before permanent storage.</p><p>The custom validation exception only needs to carry the reason the upload was rejected:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;8e5f788d-0a4a-43a5-9064-532162d0283b&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.uploads;

public class InvalidUploadException extends RuntimeException {

    public InvalidUploadException(String message) {
        super(message);
    }
}</code></pre></div><p>Throwing a dedicated exception lets the validation component stop the upload without building an HTTP response itself. Status-code decisions can stay in centralized exception handling, while the validator remains focused on deciding if the incoming file meets the endpoint rules.</p><p>Validation can grow with the needs of the endpoint. Image dimensions, document structure, permitted extensions, or other file-specific rules can be checked before storage when those properties belong to the upload contract. The main boundary stays consistent, with validation deciding if the upload is acceptable and the storage component taking responsibility for accepted data.</p><h4>Treat File Names Safely</h4><p>Client-provided filenames should be treated as metadata rather than trusted filesystem destinations. <code>MultipartFile.getOriginalFilename()</code> returns the name supplied by the client, and that value can contain directory information or sequences like <code>..</code>. Generating the stored name on the server keeps request data from deciding where a file is written.</p><p>A generated identifier can become the physical filename:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;91f9ae70-8ad2-4a52-a8ab-f60d97bec6d9&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.uploads;

import java.util.UUID;

public final class StoredFileName {

    private StoredFileName() {
    }

    public static String create() {
        return UUID.randomUUID().toString();
    }
}</code></pre></div><p><code>UUID.randomUUID()</code> produces the value stored on disk, so an incoming filename such as <code>vacation.png</code> never becomes the filesystem destination. The original filename can still be retained as metadata if the application needs to display it later or associate it with a database record.</p><p>Leaving the extension off the generated storage name also avoids trusting an extension supplied by the client. If an extension is needed later, it can be derived after the file format has passed the rules established for that upload type. The original name and declared media type can remain metadata without controlling the physical filename.</p><p>Server-generated names also prevent duplicate client filenames from replacing each other accidentally. Two uploads can both arrive as <code>photo.png</code>, while each stored file receives its own generated identifier. The name shown to a user and the name stored on disk therefore do not need to be the same value.</p><h4>Move Storage Into a Service</h4><p>Controllers stay easier to read when filesystem operations live in a dedicated service. The controller can receive the request, pass the file through validation, send accepted content to storage, and return an HTTP response. Directory creation, generated names, file transfer, and storage failures can remain inside the service.</p><p>A filesystem-backed service can keep its destination directory in application configuration:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;6e2ca113-d53a-4da4-b09a-bb18f1173fa6&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.uploads;

import java.io.File;
import java.io.IOException;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

@Service
public class FileStorageService {

    private final File uploadDirectory;

    public FileStorageService(
            @Value("${app.upload.directory}") String uploadDirectory) {

        this.uploadDirectory =
                new File(uploadDirectory).getAbsoluteFile();
    }

    public String store(MultipartFile file) {
        try {
            prepareDirectory();

            String storedName = StoredFileName.create();
            File destination =
                    new File(uploadDirectory, storedName);

            file.transferTo(destination);

            return storedName;
        }
        catch (IOException ex) {
            throw new FileStorageException(
                    "Could not store uploaded file", ex
            );
        }
    }

    private void prepareDirectory() throws IOException {
        if (uploadDirectory.exists()) {
            if (!uploadDirectory.isDirectory()) {
                throw new IOException(
                        "Upload location is not a directory"
                );
            }

            return;
        }

        if (!uploadDirectory.mkdirs()) {
            throw new IOException(
                    "Could not create upload directory"
            );
        }
    }
}</code></pre></div><p>The constructor turns the configured location into an absolute <code>File</code>, while <code>prepareDirectory()</code> checks that the location is a directory or creates it when it does not exist. After that, the service generates a server-controlled filename and passes the destination to <code>MultipartFile.transferTo(File)</code>, which transfers the received upload to that destination.</p><p>Because <code>StoredFileName.create()</code> returns a UUID value rather than client input, the destination filename does not carry directory traversal data from <code>getOriginalFilename()</code>. The service returns the generated name after the transfer finishes, giving the controller an application-level reference to the stored file.</p><p>The base directory can stay outside Java source code through <code>application.properties</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;cc457df0-22db-425f-8795-21eeb42b1b42&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">app.upload.directory=uploads</code></pre></div><p>Spring injects that value into the service constructor through <code>@Value</code>. Changing the destination then becomes a configuration change rather than a controller edit, and different environments can provide their own directory values through Spring configuration.</p><p>With validation and storage delegated, the controller becomes smaller:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;c4e596c3-a5e3-4d1f-a39c-b74fa3e3bc7d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.uploads;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
public class ImageUploadController {

    private final ImageUploadValidator validator;
    private final FileStorageService storageService;

    public ImageUploadController(
            ImageUploadValidator validator,
            FileStorageService storageService) {

        this.validator = validator;
        this.storageService = storageService;
    }

    @PostMapping("/images")
    public ResponseEntity&lt;UploadResponse&gt; uploadImage(
            @RequestParam("file") MultipartFile file) {

        validator.validate(file);

        String storedName = storageService.store(file);

        return ResponseEntity.status(HttpStatus.CREATED)
                .body(new UploadResponse(storedName));
    }

    record UploadResponse(String fileName) {
    }
}</code></pre></div><p>The request enters through <code>MultipartFile</code>, then the controller sends it to <code>ImageUploadValidator</code> before calling <code>FileStorageService</code>. Successful storage returns the generated filename, which becomes part of the <code>201 Created</code> response. Filesystem details never enter the controller method, so the HTTP flow remains easy to follow as storage requirements grow.</p><p>This boundary also leaves room for a different storage implementation later. Local disk storage can be replaced by object storage or a different persistence mechanism while the controller continues sending accepted uploads to a storage service with the same responsibility. The HTTP endpoint does not need details about how the bytes reach their final destination.</p><h4>Return Upload Errors</h4><p>Failures can happen at several points during an upload. Validation can reject empty or unsupported content, filesystem operations can fail, and multipart parsing can reject a request before the controller receives it if the configured size limit is exceeded. Central exception handling gives those failures consistent HTTP responses without adding repeated <code>try</code> and <code>catch</code> blocks to every upload controller.</p><p>Storage failures can carry their original I/O cause through a dedicated runtime exception:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;ba11a4df-25f0-4c76-900b-bbee50fe37bb&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.uploads;

public class FileStorageException extends RuntimeException {

    public FileStorageException(
            String message, Throwable cause) {

        super(message, cause);
    }
}</code></pre></div><p>Wrapping <code>IOException</code> this way lets <code>FileStorageService</code> report that persistence failed without deciding which HTTP status the client should receive. That decision can stay at the web layer, where validation failures, multipart failures, and storage failures can all be translated into HTTP responses from the same location.</p><p><code>@RestControllerAdvice</code> can handle those exceptions across REST controllers:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;52d1b7c6-46b9-4094-a25e-c6661e3667ac&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.uploads;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;

@RestControllerAdvice
public class UploadExceptionHandler {

    @ExceptionHandler(InvalidUploadException.class)
    public ResponseEntity&lt;String&gt; handleInvalidUpload(
            InvalidUploadException ex) {

        return ResponseEntity.badRequest()
                .body(ex.getMessage());
    }

    @ExceptionHandler(MaxUploadSizeExceededException.class)
    public ResponseEntity&lt;String&gt; handleMaximumSize(
            MaxUploadSizeExceededException ex) {

        return ResponseEntity.status(
                        HttpStatus.PAYLOAD_TOO_LARGE
                )
                .body("Upload exceeds the allowed size");
    }

    @ExceptionHandler(FileStorageException.class)
    public ResponseEntity&lt;String&gt; handleStorageFailure(
            FileStorageException ex) {

        return ResponseEntity.status(
                        HttpStatus.INTERNAL_SERVER_ERROR
                )
                .body("File could not be stored");
    }
}</code></pre></div><p><code>InvalidUploadException</code> becomes <code>400 Bad Request</code>, while an upload beyond the multipart maximum receives <code>413 Payload Too Large</code>. <code>FileStorageException</code> maps to <code>500 Internal Server Error</code> because the request reached storage but the server could not complete the write.</p><p>The response for a storage failure does not expose the underlying I/O exception text. Internal directory names or operating-system details do not belong in the client response, while the original exception can still remain attached to <code>FileStorageException</code> for server-side logging.</p><p>Size failures need different treatment from application validation because they can happen during multipart parsing before the controller method receives a <code>MultipartFile</code>. Controller-level <code>getSize()</code> checks therefore do not replace Spring Boot multipart limits. Configuration provides the outer request boundary, validation applies endpoint-specific rules after the request reaches application code, and centralized exception handling turns failures from both stages into HTTP responses.</p><h3>Conclusion</h3><p>Spring Boot file uploads become much easier to manage when each responsibility stays in its own place. <code>MultipartFile</code> brings the request into the controller, multipart settings set the upload boundaries, validation checks the incoming data, generated names keep client filenames away from storage decisions, and a storage service handles the final write. Central exception handling then turns validation, size, and storage failures into consistent HTTP responses without filling the controller with repeated error logic.</p><ol><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-boot%2Freference%2Fweb%2Fservlet.html">Spring Boot Multipart Configuration</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-framework%2Fdocs%2Fcurrent%2Fjavadoc-api%2Forg%2Fspringframework%2Fweb%2Fmultipart%2FMultipartFile.html">Spring Framework MultipartFile Documentation</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-framework%2Freference%2Fweb%2Fwebmvc%2Fmvc-controller%2Fann-methods%2Fmultipart-forms.html">Spring MVC Multipart Forms</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-framework%2Fdocs%2Fcurrent%2Fjavadoc-api%2Forg%2Fspringframework%2Fweb%2Fmultipart%2FMaxUploadSizeExceededException.html">Spring Framework Multipart Exceptions</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2F%3Futm_source%3Dsubstack%26utm_medium%3Demail%26utm_content%3Dshare%26action%3Dshare"><span>Share Alexander Obregon's Substack</span></a></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21d111%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F75276a31-9809-4448-8ed7-bfc98ab86c2d_276x276.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!d111!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75276a31-9809-4448-8ed7-bfc98ab86c2d_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!d111!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75276a31-9809-4448-8ed7-bfc98ab86c2d_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!d111!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75276a31-9809-4448-8ed7-bfc98ab86c2d_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!d111!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75276a31-9809-4448-8ed7-bfc98ab86c2d_276x276.png 1456w" sizes="auto, 100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21d111%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F75276a31-9809-4448-8ed7-bfc98ab86c2d_276x276.png" width="276" height="276" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/75276a31-9809-4448-8ed7-bfc98ab86c2d_276x276.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:276,&quot;width&quot;:276,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!d111!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75276a31-9809-4448-8ed7-bfc98ab86c2d_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!d111!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75276a31-9809-4448-8ed7-bfc98ab86c2d_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!d111!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75276a31-9809-4448-8ed7-bfc98ab86c2d_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!d111!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75276a31-9809-4448-8ed7-bfc98ab86c2d_276x276.png 1456w" sizes="auto, 100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Ficons8.com%2Ficon%2F90519%2Fspring-boot">Spring Boot</a> icon by <a href="iframe.php?url=https%3A%2F%2Ficons8.com%2F">Icons8</a></figcaption></figure></div>]]></content:encoded></item><item><title><![CDATA[Weekly Recap – October 11th, 2026]]></title><description><![CDATA[More Spring Boot and SQL]]></description><link>https://alexanderobregon.substack.com/p/weekly-recap-october-11th-2026</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/weekly-recap-october-11th-2026</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Sun, 11 Oct 2026 12:03:45 GMT</pubDate><content:encoded><![CDATA[<p>This last week&#8217;s two articles covered adding OpenAPI documentation and Swagger UI to Spring Boot applications and removing duplicate records in SQL with window functions. The Spring Boot post covers setting up API documentation and testing endpoints through Swagger UI, while the SQL post goes through identifying duplicates and removing them while keeping the records you want to retain.</p><p>If you&#8217;re new or want to know more about what I do, check out my <a href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fabout">About Me page.</a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fsubscribe%3F"><span>Subscribe now</span></a></p><h4><strong>Substack Articles</strong></h4><div class="digest-post-embed" data-attrs="{&quot;nodeId&quot;:&quot;45634aa6-2b73-4528-94e2-5e423d5b2c36&quot;,&quot;caption&quot;:&quot;REST APIs are much more practical to inspect when their endpoints, request bodies, responses, and authentication requirements are collected in generated documentation. springdoc-openapi reads Spring MVC controllers and builds an OpenAPI 3 description from the mappings and Java types already present in the application, while Swagger UI presen&#8230;&quot;,&quot;cta&quot;:null,&quot;showBylines&quot;:true,&quot;showDescription&quot;:true,&quot;showImage&quot;:true,&quot;size&quot;:&quot;sm&quot;,&quot;isEditorNode&quot;:true,&quot;title&quot;:&quot;How to Add OpenAPI Docs to Spring Boot with Swagger UI&quot;,&quot;publishedBylines&quot;:[{&quot;id&quot;:185785576,&quot;name&quot;:&quot;Alexander Obregon&quot;,&quot;bio&quot;:&quot;I publish on Medium and use Substack to share recaps, exclusive articles, and a way to support my programming-focused content.&quot;,&quot;photo_url&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8905d669-ef92-4534-9c0c-f486ad372c94_1905x1905.jpeg&quot;,&quot;is_guest&quot;:false,&quot;bestseller_tier&quot;:null}],&quot;post_date&quot;:&quot;2026-10-06T00:10:30.146Z&quot;,&quot;cover_image&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/a4d58e86-889a-4134-8f45-a382cc49bcfb_480x480.png&quot;,&quot;cover_image_alt&quot;:null,&quot;canonical_url&quot;:&quot;https://alexanderobregon.substack.com/p/how-to-add-openapi-docs-to-spring&quot;,&quot;section_name&quot;:&quot;Java and JVM&quot;,&quot;video_upload_id&quot;:null,&quot;id&quot;:219022286,&quot;type&quot;:&quot;newsletter&quot;,&quot;reaction_count&quot;:0,&quot;comment_count&quot;:0,&quot;publication_id&quot;:4719466,&quot;publication_name&quot;:&quot;Alexander Obregon's Substack&quot;,&quot;publication_logo_url&quot;:&quot;&quot;,&quot;belowTheFold&quot;:false,&quot;youtube_url&quot;:null,&quot;show_links&quot;:null,&quot;feed_url&quot;:null}"></div><div class="digest-post-embed" data-attrs="{&quot;nodeId&quot;:&quot;9787e409-f513-4eb0-8995-75c4d7fc412f&quot;,&quot;caption&quot;:&quot;SQL data can pick up duplicate rows through repeated imports, retry logic, merged datasets, or several records that refer to the same business item. Removing those records safely requires a rule for deciding which row should remain rather than deleting every matching value.&quot;,&quot;cta&quot;:null,&quot;showBylines&quot;:true,&quot;showDescription&quot;:true,&quot;showImage&quot;:true,&quot;size&quot;:&quot;sm&quot;,&quot;isEditorNode&quot;:true,&quot;title&quot;:&quot;Deduplication with SQL Window Functions&quot;,&quot;publishedBylines&quot;:[{&quot;id&quot;:185785576,&quot;name&quot;:&quot;Alexander Obregon&quot;,&quot;bio&quot;:&quot;I publish on Medium and use Substack to share recaps, exclusive articles, and a way to support my programming-focused content.&quot;,&quot;photo_url&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8905d669-ef92-4534-9c0c-f486ad372c94_1905x1905.jpeg&quot;,&quot;is_guest&quot;:false,&quot;bestseller_tier&quot;:null}],&quot;post_date&quot;:&quot;2026-10-08T16:17:40.985Z&quot;,&quot;cover_image&quot;:&quot;https://substackcdn.com/image/fetch/$s_!2JJq!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F515aec31-f172-41ba-93de-624ab40e2ace_306x306.png&quot;,&quot;cover_image_alt&quot;:null,&quot;canonical_url&quot;:&quot;https://alexanderobregon.substack.com/p/deduplication-with-sql-window-functions&quot;,&quot;section_name&quot;:&quot;SQL&quot;,&quot;video_upload_id&quot;:null,&quot;id&quot;:219174046,&quot;type&quot;:&quot;newsletter&quot;,&quot;reaction_count&quot;:0,&quot;comment_count&quot;:0,&quot;publication_id&quot;:4719466,&quot;publication_name&quot;:&quot;Alexander Obregon's Substack&quot;,&quot;publication_logo_url&quot;:&quot;&quot;,&quot;belowTheFold&quot;:false,&quot;youtube_url&quot;:null,&quot;show_links&quot;:null,&quot;feed_url&quot;:null}"></div><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2F%3Futm_source%3Dsubstack%26utm_medium%3Demail%26utm_content%3Dshare%26action%3Dshare"><span>Share Alexander Obregon's Substack</span></a></p>]]></content:encoded></item><item><title><![CDATA[Deduplication with SQL Window Functions]]></title><description><![CDATA[Duplicate rows can come from repeated imports, retry logic, merged datasets, or several records that refer to the same business item.]]></description><link>https://alexanderobregon.substack.com/p/deduplication-with-sql-window-functions</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/deduplication-with-sql-window-functions</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Thu, 08 Oct 2026 16:17:40 GMT</pubDate><enclosure url="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%212JJq%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F515aec31-f172-41ba-93de-624ab40e2ace_306x306.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21lI9k%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F57358719-effb-46ec-a2be-76eb99e4cc4d_800x373.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!lI9k!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57358719-effb-46ec-a2be-76eb99e4cc4d_800x373.png 424w, https://substackcdn.com/image/fetch/$s_!lI9k!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57358719-effb-46ec-a2be-76eb99e4cc4d_800x373.png 848w, https://substackcdn.com/image/fetch/$s_!lI9k!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57358719-effb-46ec-a2be-76eb99e4cc4d_800x373.png 1272w, https://substackcdn.com/image/fetch/$s_!lI9k!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57358719-effb-46ec-a2be-76eb99e4cc4d_800x373.png 1456w" sizes="100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21lI9k%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F57358719-effb-46ec-a2be-76eb99e4cc4d_800x373.png" width="800" height="373" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/57358719-effb-46ec-a2be-76eb99e4cc4d_800x373.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:373,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!lI9k!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57358719-effb-46ec-a2be-76eb99e4cc4d_800x373.png 424w, https://substackcdn.com/image/fetch/$s_!lI9k!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57358719-effb-46ec-a2be-76eb99e4cc4d_800x373.png 848w, https://substackcdn.com/image/fetch/$s_!lI9k!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57358719-effb-46ec-a2be-76eb99e4cc4d_800x373.png 1272w, https://substackcdn.com/image/fetch/$s_!lI9k!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F57358719-effb-46ec-a2be-76eb99e4cc4d_800x373.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Fcommons.wikimedia.org%2Fwiki%2FFile%3ASql_data_base_with_logo.png">Image Source</a></figcaption></figure></div><p>SQL data can pick up duplicate rows through repeated imports, retry logic, merged datasets, or several records that refer to the same business item. Removing those records safely requires a rule for deciding which row should remain rather than deleting every matching value. <code>ROW_NUMBER()</code> handles this by dividing records into duplicate groups, ordering the rows within each group, and assigning a sequence number that starts at <code>1</code>. We can treat the row numbered <code>1</code> as the preferred record, while higher numbers identify the remaining duplicates. The ordering can favor the newest date, highest priority, lowest identifier, most recent update, or another column that determines which record should stay.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fsubscribe%3F"><span>Subscribe now</span></a></p><h3>Finding Duplicate Rows</h3><p>Duplicate detection starts with deciding which columns make two records belong to the same group. That choice controls the entire query because <code>ROW_NUMBER()</code> can rank records correctly only after we define which rows should be compared with each other. Some tables may treat a repeated email address as enough to identify a duplicate, while others need several matching columns such as a customer ID, order reference, and date. From there, the window function can number rows inside each duplicate group without removing anything yet, giving us time to inspect the result before changing stored data.</p><h4>Defining the Duplicate Group</h4><p>The duplicate rule belongs inside <code>PARTITION BY</code>, where matching values are placed into the same window partition. Rows that share the partition columns are numbered as members of the same group, while different values start a new group.</p><p>We can begin with a customer table that contains repeated email addresses:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;866bd3c0-fb42-4770-b9b8-0ba9b8b44b60&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">CREATE TABLE customers (
    customer_id INT PRIMARY KEY,
    email VARCHAR(255),
    created_at TIMESTAMP
);

INSERT INTO customers (customer_id, email, created_at)
VALUES
    (101, 'alex@example.com', '2026-01-12 09:15:00'),
    (102, 'kaitlyn@example.com', '2026-01-13 11:30:00'),
    (103, 'alex@example.com', '2026-02-04 14:20:00'),
    (104, 'pippin@example.com', '2026-02-08 08:45:00'),
    (105, 'kaitlyn@example.com', '2026-03-01 16:10:00');</code></pre></div><p>We now have two email addresses that appear more than once. <code>alex@example.com</code> appears twice, while <code>kaitlyn@example.com</code> also appears twice. The rows still carry different <code>customer_id</code> and <code>created_at</code> values, so they are not exact copies across every column, but they can still count as duplicates if email is the business value we care about.</p><p>We can assign row numbers inside each email group like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;bc1d1467-faeb-4048-9ea2-ff6f135bb8fd&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    customer_id,
    email,
    created_at,
    ROW_NUMBER() OVER (
        PARTITION BY email
        ORDER BY created_at
    ) AS row_num
FROM customers;</code></pre></div><p><code>PARTITION BY email</code> creates a new numbering group for every distinct email address. Within each group, <code>ORDER BY created_at</code> arranges the records from earliest to latest before <code>ROW_NUMBER()</code> assigns <code>1</code>, <code>2</code>, and later values. Email addresses that appear only once still receive <code>1</code> because their partition contains a single record.</p><p>The duplicate definition can also depend on more than one column. Order data is a good case because the same reference value may be valid for different customers, while the same customer and reference combination may represent a repeated record.</p><p>We can express that rule by partitioning on both values:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;8b1ac1de-e41b-4626-9250-14fad5b819e3&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    order_id,
    customer_id,
    order_reference,
    created_at,
    ROW_NUMBER() OVER (
        PARTITION BY customer_id, order_reference
        ORDER BY created_at
    ) AS row_num
FROM orders;</code></pre></div><p>Now two records belong to the same group only when both <code>customer_id</code> and <code>order_reference</code> match. Matching just one of those columns is no longer enough, which gives the query a more specific duplicate rule.</p><p>That distinction matters because <code>PARTITION BY</code> does not decide what duplicate means by itself. We make that decision through the columns placed inside the partition. Too few columns can group unrelated records, while too many can split records that should have been treated as duplicates because some less important value differs.</p><h4>Reading the Row Numbers</h4><p>After the groups are defined, <code>ROW_NUMBER()</code> gives every row a sequence number inside its partition. Numbering starts at <code>1</code> and increases according to the window <code>ORDER BY</code>, which gives us a direct way to tell the first record apart from later records in the same group.</p><p>The first row in every partition receives <code>row_num = 1</code>, while later rows receive <code>2</code>, <code>3</code>, and so on. Unique records receive <code>1</code> as well because there is no second row in their partition. That gives us a practical dividing point because values greater than <code>1</code> can be treated as duplicate candidates. Rather than filtering directly on the window expression at the same query level, we can calculate the row number first and then filter it from an outer query.</p><p>A common table expression can work well here:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;dd7d71ad-6f1b-43bf-98bc-57eee1c5f0a4&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">WITH ranked_customers AS (
    SELECT
        customer_id,
        email,
        created_at,
        ROW_NUMBER() OVER (
            PARTITION BY email
            ORDER BY created_at
        ) AS row_num
    FROM customers
)
SELECT
    customer_id,
    email,
    created_at,
    row_num
FROM ranked_customers
WHERE row_num &gt; 1;</code></pre></div><p>The common table expression performs the ranking first, which gives the outer query a normal <code>row_num</code> column to filter. The final <code>WHERE row_num &gt; 1</code> condition removes position <code>1</code> from the displayed result and leaves only later records from repeated email groups.</p><p>We can read the output as a duplicate report rather than a deletion instruction. A row numbered <code>2</code> tells us that one record from the same partition came before it, while a row numbered <code>3</code> tells us that two records from that group ranked ahead of it. This also helps when a duplicate group contains several rows. If an email appears four times, the sequence becomes <code>1</code>, <code>2</code>, <code>3</code>, and <code>4</code>, which lets us see the size of the repeated group while still keeping every record available for inspection.</p><p>If we only wanted to know which values repeat, <code>GROUP BY</code> with <code>COUNT(*)</code> could answer that question. <code>ROW_NUMBER()</code> goes further because every original row stays available and receives its own position, which becomes important when later logic needs to identify specific records rather than only count them.</p><h4>Controlling Which Row Comes First</h4><p>The duplicate group comes from <code>PARTITION BY</code>, while the record that receives <code>1</code> comes from the window <code>ORDER BY</code>. Because of that split, the same group of duplicate rows can produce different first records depending on how we sort them.</p><p>If the earliest customer record should come first, ascending date order gives us that result:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;ff76a4bb-78a9-4cde-8f9b-5f364a8dda7e&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">ROW_NUMBER() OVER (
    PARTITION BY email
    ORDER BY created_at ASC
)</code></pre></div><p>Within each email group, the smallest <code>created_at</code> value receives <code>1</code>, and later dates receive higher numbers. The duplicate grouping has not changed because <code>PARTITION BY email</code> remains the same.</p><p>If the newest record should come first, we can reverse the date order:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;dc76b7d6-f63e-4fce-8fcc-6c1c19e0e100&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">ROW_NUMBER() OVER (
    PARTITION BY email
    ORDER BY created_at DESC
)</code></pre></div><p>The latest timestamp now receives <code>1</code>, followed by older records in descending date order. We are still ranking the same duplicate group, but the preferred row changes because the ordering rule changed.</p><p>Ties need more detail in the ordering rule. Two duplicate records can share the same <code>created_at</code> timestamp, and if that is the only ordering column, the query has no extra value that decides which tied row receives the lower row number.</p><p>We can add a unique identifier as the final tiebreaker:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;ff938aa4-5c7e-4d9e-a041-7596a9ad3a11&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">ROW_NUMBER() OVER (
    PARTITION BY email
    ORDER BY created_at DESC, customer_id DESC
)</code></pre></div><p>The newest date still takes precedence. If two rows share that date, the larger <code>customer_id</code> comes first because the second expression also uses descending order. Several columns can be chained this way when the ranking rule needs more detail. SQL evaluates the ordering expressions from left to right, so the first column has the highest priority, the next resolves ties from that result, and later columns continue resolving any remaining ties.</p><p>Priority values can be placed before dates as well if the data calls for that. The underlying idea stays the same. <code>PARTITION BY</code> decides which records compete within the group, while <code>ORDER BY</code> decides which one receives the earliest row number.</p><p>Changing the ordering does not change which rows count as duplicates. It changes only their sequence inside the duplicate group, which is why this part of the query becomes so important before we later decide which records should remain.</p><h4>Inspecting Duplicate Candidates</h4><p>Before changing any rows, it helps to display the full ranking so we can verify the grouping and ordering rules against the records themselves. That gives us a chance to check which row received <code>1</code>, which rows received higher numbers, and how tied values are being resolved.</p><p>We can sort the ranked result by email and row number so duplicate groups appear next to each other:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;3bd6d1d2-501b-4107-854b-f6ac0d876d1a&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">WITH ranked_customers AS (
    SELECT
        customer_id,
        email,
        created_at,
        ROW_NUMBER() OVER (
            PARTITION BY email
            ORDER BY created_at DESC, customer_id DESC
        ) AS row_num
    FROM customers
)
SELECT
    customer_id,
    email,
    created_at,
    row_num
FROM ranked_customers
ORDER BY email, row_num;</code></pre></div><p>The result places each email group next to itself, with the preferred row first and later records directly below it. Unique email addresses appear only with <code>row_num = 1</code>, while repeated addresses continue to higher numbers.</p><p>Reading the complete ranking first gives us a better view of the duplicate rule. If an unexpected row receives <code>1</code>, we can revisit the window ordering before doing anything with the higher-numbered records. If unrelated rows appear in the same group, the partition columns need to be changed.</p><p>After the full ranking looks right, we can narrow the result to duplicate candidates:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;beaaa4dc-ebc6-486f-95a2-20740de89f35&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">WITH ranked_customers AS (
    SELECT
        customer_id,
        email,
        created_at,
        ROW_NUMBER() OVER (
            PARTITION BY email
            ORDER BY created_at DESC, customer_id DESC
        ) AS row_num
    FROM customers
)
SELECT
    customer_id,
    email,
    created_at
FROM ranked_customers
WHERE row_num &gt; 1
ORDER BY email, row_num;</code></pre></div><p>The ranking still runs across every row, while the outer query displays only records numbered above <code>1</code>. Those are the rows that came after the preferred record within their duplicate group.</p><p>The same inspection method can handle more detailed duplicate rules without changing the basic structure. If records should match on <code>customer_id</code>, <code>product_id</code>, and <code>order_date</code>, those columns can all go inside <code>PARTITION BY</code>, while the ordering columns continue deciding which matching record receives the first position.</p><p>Keeping every duplicate candidate readable at this stage gives us room to review the grouping and row numbers before later logic acts on them. That keeps the ranking tied directly to the rules expressed inside the window function and makes it easier to verify which records belong to each duplicate group.</p><h3>Keeping the Preferred Record</h3><p>After duplicate groups have been identified, the next step is deciding which record should remain from each group. That decision usually comes from values already stored in the table, such as a timestamp, priority level, status, source ranking, or identifier. <code>ROW_NUMBER()</code> gives us a practical way to express that preference because the row placed first inside each partition receives <code>1</code>, while later rows receive higher values. From there, records above <code>1</code> can be reviewed or removed without changing the preferred row.</p><h4>Keeping Records by Date</h4><p>Date-based rules are common when repeated imports, updates, or retries create several records for the same entity. If the newest version should remain, descending date order places the latest timestamp first inside every duplicate group, giving that row position <code>1</code>.</p><p>We can start with a contact import table where repeated email addresses have different phone numbers and import times:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;c2d4633f-0a0f-4e39-961b-d992cd7edbbc&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">CREATE TABLE contact_imports (
    import_id INT PRIMARY KEY,
    email VARCHAR(255),
    phone VARCHAR(30),
    imported_at TIMESTAMP
);

INSERT INTO contact_imports (import_id, email, phone, imported_at)
VALUES
    (201, 'alex@example.com', '715-555-0101', '2026-04-02 08:00:00'),
    (202, 'alex@example.com', '715-555-0188', '2026-04-06 10:30:00'),
    (203, 'kaitlyn@example.com', '715-555-0124', '2026-04-03 12:15:00'),
    (204, 'kaitlyn@example.com', '715-555-0197', '2026-04-09 09:40:00');</code></pre></div><p>Each repeated email address has an older record and a newer record. Because the phone number can change between imports, keeping an arbitrary row could leave older contact data in place, so the timestamp needs to control the ranking.</p><p>We can rank the newest import first:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;6db83860-f87b-42d5-9bb5-7e9ca48a70c7&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">WITH ranked_imports AS (
    SELECT
        import_id,
        email,
        phone,
        imported_at,
        ROW_NUMBER() OVER (
            PARTITION BY email
            ORDER BY imported_at DESC, import_id DESC
        ) AS row_num
    FROM contact_imports
)
SELECT
    import_id,
    email,
    phone,
    imported_at,
    row_num
FROM ranked_imports
ORDER BY email, row_num;</code></pre></div><p><code>PARTITION BY email</code> keeps matching email addresses in the same group, while <code>ORDER BY imported_at DESC</code> places the newest import first. The <code>import_id DESC</code> expression provides a final tiebreaker when two rows share the same timestamp, giving the ranking a defined order in that case.</p><p>For <code>alex@example.com</code>, the April 6 record receives <code>row_num = 1</code>, while the April 2 record receives <code>2</code>. The same rule applies to the Kaitlyn records, where the April 9 row comes before the April 3 row. We can read those numbers directly as the retention order, with position <code>1</code> representing the record we plan to keep.</p><p>After checking the ranking, rows above <code>1</code> can be targeted for removal. PostgreSQL can delete the identifiers returned from the ranked result like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;d4dbfe87-57c8-4ef6-8e91-98150eae5e88&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">WITH ranked_imports AS (
    SELECT
        import_id,
        ROW_NUMBER() OVER (
            PARTITION BY email
            ORDER BY imported_at DESC, import_id DESC
        ) AS row_num
    FROM contact_imports
)
DELETE FROM contact_imports
WHERE import_id IN (
    SELECT import_id
    FROM ranked_imports
    WHERE row_num &gt; 1
);</code></pre></div><p>The common table expression assigns the ranking first, while the inner <code>SELECT</code> returns only identifiers whose row number is greater than <code>1</code>. The outer <code>DELETE</code> removes those specific records, leaving the preferred row from every email group in place.</p><p>Date rules can also keep the earliest record rather than the newest. Changing <code>DESC</code> to <code>ASC</code> reverses which timestamp receives position <code>1</code>, while <code>PARTITION BY email</code> continues grouping the same records. That small change can support data where the original record should remain instead of the latest update.</p><h4>Keeping Records by Priority</h4><p>Time does not always decide which duplicate should remain. Some tables contain a source rank, status value, score, approval level, or similar column that takes precedence when several rows represent the same item.</p><p>Product feeds are a good case because the same product code can arrive from different sources, and some sources may carry a higher preference than others. If a larger <code>source_priority</code> means the record should come first, we can place that column at the beginning of the window ordering:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;d60af112-841f-4ec8-8f09-57e41795c08a&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    feed_id,
    product_code,
    source_priority,
    updated_at,
    ROW_NUMBER() OVER (
        PARTITION BY product_code
        ORDER BY source_priority DESC,
                 updated_at DESC,
                 feed_id DESC
    ) AS row_num
FROM product_feed;</code></pre></div><p>The ranking starts by grouping matching <code>product_code</code> values. Inside each product group, the highest <code>source_priority</code> receives the earliest position. If two records share the same priority, <code>updated_at DESC</code> favors the newer record, while <code>feed_id DESC</code> resolves any remaining tie.</p><p>Column order inside <code>ORDER BY</code> directly controls the preference hierarchy. If <code>updated_at DESC</code> appeared before <code>source_priority DESC</code>, a newer record from a lower-priority source could receive <code>1</code> ahead of an older record from the preferred source. Placing priority first tells the query that source ranking comes before recency. Several preference columns can follow each other when the retention rule needs more detail. Records could rank by approval state first, then source priority, then update time, with a unique identifier at the end to resolve any remaining tie. SQL evaluates those expressions from left to right, so every later column handles ties left by the earlier column.</p><p>Null values also deserve attention when a preference column can be empty. PostgreSQL supports <code>NULLS FIRST</code> and <code>NULLS LAST</code>, which lets us decide where null values belong instead of relying on the default sort behavior.</p><p>If rows with an actual update timestamp should rank ahead of rows where <code>updated_at</code> is null, we can write:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;051885cd-7614-4f74-b995-0ec050daec57&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">ROW_NUMBER() OVER (
    PARTITION BY product_code
    ORDER BY updated_at DESC NULLS LAST,
             feed_id DESC
)</code></pre></div><p>Rows with timestamps now come before rows without them, while <code>feed_id</code> resolves ties among equal timestamps or among records where the timestamp is null. That keeps the row-number sequence aligned with the retention rule rather than allowing null placement to decide the result indirectly.</p><p>Priority-based ranking follows the same mechanics as date-based ranking. The main difference comes from the column placed first inside the window <code>ORDER BY</code>, because that first expression has the greatest influence over which record receives position <code>1</code>.</p><h4>Preventing Duplicates From Returning</h4><p>Removing existing duplicates fixes the current records, but repeated cleanup queries should not become the normal way to control values that are never supposed to repeat. If the data rules say a value must be unique, a database constraint can enforce that requirement when later inserts or updates occur.</p><p>After the existing duplicate emails have been removed from <code>customers</code>, a unique constraint can prevent the same email from being stored more than once:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;36166af5-8801-4f9f-bd83-ca198dd8ddf6&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">ALTER TABLE customers
ADD CONSTRAINT uq_customers_email UNIQUE (email);</code></pre></div><p>After the constraint is added, an insert or update that would create a duplicate email is rejected by the database. That moves the duplicate rule closer to the point where data enters the table rather than waiting until repeated records need to be removed later.</p><p>Some uniqueness rules depend on more than one column. An order reference may repeat across different customers while still needing to remain unique for each individual customer.</p><p>We can express that rule with a multi-column unique constraint:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;cc19bff9-7ec5-4744-8eb7-9c3a9dad3586&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">ALTER TABLE orders
ADD CONSTRAINT uq_customer_order_reference
UNIQUE (customer_id, order_reference);</code></pre></div><p>The combination of <code>customer_id</code> and <code>order_reference</code> must now remain unique. The same <code>order_reference</code> can still appear for a different customer because the full pair differs.</p><p>That matches the duplicate grouping we could define with <code>PARTITION BY customer_id, order_reference</code>. The window query identifies repeated combinations that already exist, while the constraint blocks later rows that would violate the same rule.</p><p>Constraints still need to reflect what the table represents. Event logs, audit tables, history tables, and versioned records can legitimately contain repeated business values because every row may represent a different event or state. In those tables, repeated values can be expected data rather than records that should automatically be rejected.</p><p>Incoming data can also be ranked before it reaches the destination table. A staging table can collect repeated records, then <code>ROW_NUMBER()</code> can rank them before only the preferred rows move forward.</p><p>We could keep the newest staging record for every email like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;4507afcc-7622-4bc5-a9cc-2eea7dfd3ee3&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">WITH ranked_stage AS (
    SELECT
        customer_id,
        email,
        created_at,
        ROW_NUMBER() OVER (
            PARTITION BY email
            ORDER BY created_at DESC, customer_id DESC
        ) AS row_num
    FROM customer_stage
)
SELECT
    customer_id,
    email,
    created_at
FROM ranked_stage
WHERE row_num = 1;</code></pre></div><p>The staging records are grouped by email, ranked from newest to oldest, then filtered so only position <code>1</code> remains in the result. That result can feed a later insert, keeping duplicate handling earlier in the data flow rather than waiting until repeated rows have accumulated in the destination table.</p><p>Over these cases, <code>ROW_NUMBER()</code> follows the same mechanics. <code>PARTITION BY</code> defines which records belong to the same duplicate group, while the window <code>ORDER BY</code> decides which record takes position <code>1</code>. The columns chosen for that ordering carry the retention rule, giving us direct control over which row remains before higher-numbered records are removed.</p><h3>Conclusion</h3><p>SQL deduplication with <code>ROW_NUMBER()</code> comes down to grouping matching records with <code>PARTITION BY</code>, ordering each group so the preferred row receives <code>1</code>, then treating higher row numbers as duplicate candidates. Dates, priorities, and tiebreaker columns let us control which record stays, while unique constraints can stop unwanted duplicates from being added later.</p><ol><li><p><em><a href="iframe.php?url=https%3A%2F%2Fwww.postgresql.org%2Fdocs%2Fcurrent%2Ffunctions-window.html">PostgreSQL Window Functions Documentation</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Flearn.microsoft.com%2Fen-us%2Fsql%2Ft-sql%2Ffunctions%2Frow-number-transact-sql%3Fview%3Dsql-server-ver17">SQL Server ROW_NUMBER Documentation</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.oracle.com%2Fen%2Fdatabase%2Foracle%2Foracle-database%2F26%2Fsqlrf%2FROW_NUMBER.html">Oracle ROW_NUMBER Documentation</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fwww.postgresql.org%2Fdocs%2Fcurrent%2Fddl-constraints.html">PostgreSQL Unique Constraints Documentation</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2F%3Futm_source%3Dsubstack%26utm_medium%3Demail%26utm_content%3Dshare%26action%3Dshare"><span>Share Alexander Obregon's Substack</span></a></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%212JJq%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F515aec31-f172-41ba-93de-624ab40e2ace_306x306.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!2JJq!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F515aec31-f172-41ba-93de-624ab40e2ace_306x306.png 424w, https://substackcdn.com/image/fetch/$s_!2JJq!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F515aec31-f172-41ba-93de-624ab40e2ace_306x306.png 848w, https://substackcdn.com/image/fetch/$s_!2JJq!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F515aec31-f172-41ba-93de-624ab40e2ace_306x306.png 1272w, https://substackcdn.com/image/fetch/$s_!2JJq!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F515aec31-f172-41ba-93de-624ab40e2ace_306x306.png 1456w" sizes="auto, 100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%212JJq%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F515aec31-f172-41ba-93de-624ab40e2ace_306x306.png" width="306" height="306" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/515aec31-f172-41ba-93de-624ab40e2ace_306x306.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:306,&quot;width&quot;:306,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!2JJq!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F515aec31-f172-41ba-93de-624ab40e2ace_306x306.png 424w, https://substackcdn.com/image/fetch/$s_!2JJq!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F515aec31-f172-41ba-93de-624ab40e2ace_306x306.png 848w, https://substackcdn.com/image/fetch/$s_!2JJq!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F515aec31-f172-41ba-93de-624ab40e2ace_306x306.png 1272w, https://substackcdn.com/image/fetch/$s_!2JJq!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F515aec31-f172-41ba-93de-624ab40e2ace_306x306.png 1456w" sizes="auto, 100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Ffreesvg.org%2Fmono-sql">Image Source</a></figcaption></figure></div>]]></content:encoded></item><item><title><![CDATA[How to Add OpenAPI Docs to Spring Boot with Swagger UI]]></title><description><![CDATA[REST APIs are much more practical to inspect when their endpoints, request bodies, responses, and authentication requirements are collected in generated documentation.]]></description><link>https://alexanderobregon.substack.com/p/how-to-add-openapi-docs-to-spring</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/how-to-add-openapi-docs-to-spring</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Tue, 06 Oct 2026 00:10:30 GMT</pubDate><enclosure url="iframe.php?url=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa4d58e86-889a-4134-8f45-a382cc49bcfb_480x480.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21L4aS%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fd6d54cc8-de97-4d9a-ac4c-31846e3e5234_800x444.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!L4aS!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6d54cc8-de97-4d9a-ac4c-31846e3e5234_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!L4aS!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6d54cc8-de97-4d9a-ac4c-31846e3e5234_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!L4aS!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6d54cc8-de97-4d9a-ac4c-31846e3e5234_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!L4aS!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6d54cc8-de97-4d9a-ac4c-31846e3e5234_800x444.jpeg 1456w" sizes="100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21L4aS%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fd6d54cc8-de97-4d9a-ac4c-31846e3e5234_800x444.jpeg" width="800" height="444" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/d6d54cc8-de97-4d9a-ac4c-31846e3e5234_800x444.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:444,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!L4aS!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6d54cc8-de97-4d9a-ac4c-31846e3e5234_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!L4aS!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6d54cc8-de97-4d9a-ac4c-31846e3e5234_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!L4aS!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6d54cc8-de97-4d9a-ac4c-31846e3e5234_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!L4aS!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6d54cc8-de97-4d9a-ac4c-31846e3e5234_800x444.jpeg 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Fspring.io%2Fprojects%2Fspring-boot">Image Source</a></figcaption></figure></div><p>REST APIs are much more practical to inspect when their endpoints, request bodies, responses, and authentication requirements are collected in generated documentation. <code>springdoc-openapi</code> reads Spring MVC controllers and builds an OpenAPI 3 description from the mappings and Java types already present in the application, while Swagger UI presents that information in a browser where we can review endpoints and send requests. Much of the documentation comes directly from the existing controller structure, and OpenAPI annotations let us add details that Spring cannot infer from the code alone. We&#8217;ll begin with the generated document, see what Springdoc discovers from the controllers, then add operation descriptions, response definitions, and request schemas.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fsubscribe%3F"><span>Subscribe now</span></a></p><h3>Steps to Generate OpenAPI Documentation</h3><p>Before adding custom descriptions or response details, we can let Springdoc read what the application already exposes. Spring MVC controller mappings, HTTP methods, parameters, request bodies, return types, and validation metadata give Springdoc enough information to build an OpenAPI 3 document with very little documentation-specific code. Swagger UI then reads that document and presents the endpoints in a browser, which gives us a practical place to inspect what Springdoc found before adding richer metadata later.</p><h4>Add Springdoc OpenAPI</h4><p>The first step is adding the Springdoc starter that matches the Spring Boot version and web stack in the project. For a Spring Boot 4 application built with Spring MVC that needs both the generated OpenAPI document and Swagger UI, Maven can include <code>springdoc-openapi-starter-webmvc-ui</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;toml&quot;,&quot;nodeId&quot;:&quot;314618f1-2654-486f-8403-2778fb595c1b&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-toml">&lt;dependency&gt;
    &lt;groupId&gt;org.springdoc&lt;/groupId&gt;
    &lt;artifactId&gt;springdoc-openapi-starter-webmvc-ui&lt;/artifactId&gt;
    &lt;version&gt;3.1.1&lt;/version&gt;
&lt;/dependency&gt;</code></pre></div><p>This starter brings Spring MVC integration and Swagger UI into the application. After the dependency is present, Springdoc can inspect registered REST controllers and build documentation from the mappings and Java types it finds there. We do not need to create an OpenAPI YAML file by hand before documentation appears, and controller methods do not need OpenAPI annotations before Springdoc can discover them.</p><p>The <code>webmvc-ui</code> portion of the artifact name identifies the Spring MVC starter that also includes Swagger UI support. Projects built on Spring WebFlux use the corresponding <code>springdoc-openapi-starter-webflux-ui</code> artifact instead, so the dependency should match the web framework already present in the project.</p><p>Gradle projects can add the same Springdoc module through <code>build.gradle</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;2bd68a8b-d2a9-4db2-9af2-127cd3247737&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.1.1'</code></pre></div><p>The Gradle declaration brings in the same Springdoc module as the Maven dependency. Only the build tool changes, while Springdoc still reads Spring MVC controllers and publishes the same OpenAPI endpoints after startup.</p><p>No extra controller registration is required for the basic generated documentation. Springdoc discovers Spring MVC request mappings from the application context and translates that information into OpenAPI operations, so the generated document stays connected to the controller definitions rather than depending on a second manually maintained route list.</p><h4>Read Existing Controllers</h4><p>Controller metadata gives Springdoc much of the structure it needs before we add documentation annotations. We can start with a small REST controller that retrieves books and creates new ones:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;84d4895f-1829-4b49-b7c8-a2ddfa733891&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.books;

import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/books")
public class BookController {

    private final BookService bookService;

    public BookController(BookService bookService) {
        this.bookService = bookService;
    }

    @GetMapping("/{id}")
    public ResponseEntity&lt;BookResponse&gt; getBook(@PathVariable Long id) {
        return bookService.findById(id)
                .map(ResponseEntity::ok)
                .orElseGet(() -&gt; ResponseEntity.notFound().build());
    }

    @PostMapping
    public ResponseEntity&lt;BookResponse&gt; createBook(
            @Valid @RequestBody BookRequest request) {

        BookResponse book = bookService.create(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(book);
    }
}</code></pre></div><p>Springdoc reads <code>@RequestMapping("/api/books")</code> as the shared base path for the controller. The <code>@GetMapping("/{id}")</code> method becomes a <code>GET</code> operation for <code>/api/books/{id}</code>, while <code>@PostMapping</code> becomes a <code>POST</code> operation for <code>/api/books</code>. From the method parameters, Springdoc can identify that <code>id</code> belongs in the request path and that <code>BookRequest</code> represents the request body sent to the creation endpoint.</p><p>Return types add more information to the generated document. <code>ResponseEntity&lt;BookResponse&gt;</code> tells Springdoc that the endpoint can return data represented by <code>BookResponse</code>, which allows that Java type to appear as an OpenAPI schema. The request type follows the same idea, so fields declared inside <code>BookRequest</code> can become part of the request body schema without creating a second model only for documentation.</p><p>Validation metadata can contribute to those generated schemas as well. If the request record requires values through Bean Validation, Springdoc can reflect supported constraints while reading the DTO:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;88e8e9ca-639a-4e3c-aac7-431932cabe94&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.books;

import jakarta.validation.constraints.NotBlank;

public record BookRequest(
        @NotBlank String title,
        @NotBlank String author
) {
}</code></pre></div><p>Both fields carry <code>@NotBlank</code>, while <code>@Valid</code> on the controller request body tells Spring to run Bean Validation when the request arrives. Springdoc can read supported validation metadata from the same request model, so the OpenAPI schema can carry information derived from the Java contract rather than from a duplicate documentation model.</p><p>The generated description follows controller changes because Springdoc reads the mappings Spring registers in the application context. If a path variable name changes, a route moves, or a new controller method is added, the OpenAPI document reflects the controller metadata available when the description is generated. This keeps the generated route information tied directly to the HTTP endpoints defined by Spring MVC.</p><p>Springdoc can infer structure from Java and Spring annotations, but some details still need to be supplied later. Method signatures, Java types, and mappings tell it what the API exposes, while richer descriptions, response definitions, request examples, and security metadata can be added where the generated information needs more context.</p><h4>Open the Generated Docs</h4><p>After the application starts, Springdoc publishes the OpenAPI document at <code>/v3/api-docs</code> by default. Opening that endpoint returns JSON that follows the OpenAPI 3 format and contains the operations, paths, schemas, parameters, and other metadata collected from the application.</p><p>The YAML form is available at <code>/v3/api-docs.yaml</code>. Both endpoints represent the same API description in different formats, so we can choose the format that matches the tool or workflow reading the document. Browser inspection, API tooling, generated clients, and other OpenAPI-aware software can consume the document directly.</p><p>Swagger UI provides the browser interface at <code>/swagger-ui.html</code>. The page reads the OpenAPI document generated by Springdoc and presents its contents as interactive API documentation. We can expand an endpoint, inspect its parameters and schemas, enter request data, and send requests from the browser when the endpoint allows it.</p><p>The JSON endpoint also helps when we want to inspect exactly what Springdoc discovered from the controllers. If a request model does not appear as expected or a route contains unexpected metadata, <code>/v3/api-docs</code> exposes the generated OpenAPI structure directly, while Swagger UI presents the same information through its browser interface.</p><p>Projects can move the default documentation locations through Springdoc properties. If we want shorter routes for both the document and Swagger UI, <code>application.properties</code> can contain:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;toml&quot;,&quot;nodeId&quot;:&quot;517f4544-1f33-465f-9aa0-b83229fabf1e&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-toml">springdoc.api-docs.path=/api-docs
springdoc.swagger-ui.path=/docs</code></pre></div><p>With those values in place, the OpenAPI JSON document moves from <code>/v3/api-docs</code> to <code>/api-docs</code>, while Swagger UI moves from <code>/swagger-ui.html</code> to <code>/docs</code>. The REST controller paths remain unchanged because these properties affect Springdoc endpoints rather than application routes such as <code>/api/books</code>.</p><p>Changing those paths can help when an application follows its own URL conventions or places documentation behind routing rules outside the controller layer. The generated content still comes from the same Spring MVC metadata, so moving the documentation endpoint does not change what Springdoc discovers from the controllers.</p><p>At this stage, we have the main documentation flow in place. Springdoc reads the registered REST controllers, produces an OpenAPI 3 document from their mappings and Java types, and Swagger UI presents that document in the browser. That gives us the base needed before adding richer endpoint descriptions, response metadata, and schema details.</p><h3>Giving the OpenAPI Docs More Detail</h3><p>The generated OpenAPI document already knows a lot about the controller from Spring MVC annotations, method parameters, and Java types. From there, we can add the information that cannot be inferred directly from method signatures, such as what an endpoint does, which HTTP responses callers should expect, and what individual request fields represent. Spring MVC still controls request handling, while OpenAPI annotations supply documentation metadata beside the controller and DTO code.</p><h4>Explain Each Operation</h4><p>Controller mappings tell Springdoc which route exists and which HTTP method belongs to it. <code>@Operation</code> lets us add a short summary along with a longer explanation so someone reading Swagger UI does not have to infer the endpoint purpose from a Java method name.</p><p>We can place that metadata directly above the existing book lookup endpoint:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;4a5e156a-3bcd-484a-8825-9818dabe2730&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import io.swagger.v3.oas.annotations.Operation;

@Operation(
        summary = "Get a book by ID",
        description = "Returns the book associated with the supplied database ID."
)
@GetMapping("/{id}")
public ResponseEntity&lt;BookResponse&gt; getBook(@PathVariable Long id) {
    return bookService.findById(id)
            .map(ResponseEntity::ok)
            .orElseGet(() -&gt; ResponseEntity.notFound().build());
}</code></pre></div><p><code>@GetMapping</code> still controls the request route, while <code>@Operation</code> contributes documentation for the generated OpenAPI operation. Springdoc gets the path, HTTP method, route variable, and return type from the controller method, then includes the summary and longer text beside that information.</p><p>Short summaries are usually best when they state the action directly. <code>Get a book by ID</code> tells someone what the endpoint does without repeating the full route, while the longer text can explain details that are not obvious from the method name.</p><p>Route variables and query parameters can receive their own metadata through <code>@Parameter</code>. We can give the existing <code>id</code> variable more context without changing how Spring MVC binds it:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;5d6a7e14-3521-4c93-941d-cb9f383520b9&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import io.swagger.v3.oas.annotations.Parameter;

@GetMapping("/{id}")
public ResponseEntity&lt;BookResponse&gt; getBook(
        @Parameter(
                description = "Database ID of the book",
                example = "15"
        )
        @PathVariable Long id) {

    return bookService.findById(id)
            .map(ResponseEntity::ok)
            .orElseGet(() -&gt; ResponseEntity.notFound().build());
}</code></pre></div><p><code>@PathVariable</code> still reads the value from the URL, while <code>@Parameter</code> contributes documentation for that value. The sample gives Swagger UI data it can display beside the field, and the accompanying text tells the reader what the number represents.</p><p>Springdoc can already tell that <code>id</code> belongs to the path from the Spring MVC mapping, so there is little value in repeating that fact through extra metadata. Parameter notes are better suited to information such as units, accepted formats, identifier meaning, or restrictions that the Java name does not communicate by itself.</p><p>The same idea applies to query parameters. Spring MVC defines how the value reaches the controller method, while OpenAPI annotations add context for readers without replacing the mapping that handles the request.</p><h4>Map Out API Responses</h4><p>Return types give Springdoc part of the response information, but a controller method can return more than a single HTTP status. The book lookup returns <code>200 OK</code> when a matching record exists and <code>404 Not Found</code> when no book matches the supplied ID, so both outcomes belong in the generated documentation.</p><p>We can record those responses beside the controller method:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;15f04b8c-f92c-4fe1-b394-9877378f3564&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;

@Operation(summary = "Get a book by ID")
@ApiResponses({
        @ApiResponse(
                responseCode = "200",
                description = "Book found",
                content = @Content(
                        mediaType = "application/json",
                        schema = @Schema(implementation = BookResponse.class)
                )
        ),
        @ApiResponse(
                responseCode = "404",
                description = "Book not found",
                content = @Content
        )
})
@GetMapping("/{id}")
public ResponseEntity&lt;BookResponse&gt; getBook(@PathVariable Long id) {
    return bookService.findById(id)
            .map(ResponseEntity::ok)
            .orElseGet(() -&gt; ResponseEntity.notFound().build());
}</code></pre></div><p><code>@ApiResponses</code> groups the known outcomes for the endpoint, while every <code>@ApiResponse</code> represents a status the caller can receive. The <code>200</code> entry declares JSON content backed by <code>BookResponse</code>, so the generated OpenAPI document can associate that response with the proper schema. The <code>404</code> branch has no response body in this controller, so its content stays empty.</p><p>These annotations document behavior that already exists in the controller. Declaring a <code>404</code> response does not make Spring MVC return that status, and declaring <code>BookResponse</code> as the <code>200</code> schema does not alter the value returned by the method. The code still controls the HTTP behavior, which means the response metadata should stay aligned with what the endpoint actually returns.</p><p>Creation endpoints can follow the same idea, if <code>POST /api/books</code> returns <code>201 Created</code>, the OpenAPI metadata can record that status along with the response schema. Validation failures can also appear as <code>400 Bad Request</code> when that matches the exception handling configured by the application. Response bodies deserve the same attention as response codes. If an error branch returns a structured error object, the matching <code>@ApiResponse</code> can reference that class through <code>@Schema</code>. If no body is returned, leaving the content empty prevents Swagger UI from advertising a response model callers will never receive.</p><p>HTTP headers can also be documented through OpenAPI response metadata when an endpoint returns information there. The main goal is to make the generated response section match the contract exposed by the controller rather than treating every endpoint as if it only returns its successful Java type.</p><h4>Give Request Models More Context</h4><p>Request DTOs already tell Springdoc the field names and Java types that belong in the request body. <code>@Schema</code> lets us attach extra information to those fields, including text for readers and sample values that Swagger UI can present beside the generated model.</p><p>We can keep validation rules on the request record while adding schema metadata beside them:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;61e294e1-b978-4fec-8efd-08bd572a71bd&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.books;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;

public record BookRequest(

        @Schema(
                description = "Title of the book",
                example = "Spring in Action"
        )
        @NotBlank
        String title,

        @Schema(
                description = "Author of the book",
                example = "Craig Walls"
        )
        @NotBlank
        String author
) {
}</code></pre></div><p><code>@Schema</code> contributes documentation for the properties, while <code>@NotBlank</code> remains a Bean Validation rule checked when the controller receives a request marked with <code>@Valid</code>. Springdoc can read supported validation metadata from the same Java type, so the OpenAPI schema stays tied to the DTO the endpoint actually receives.</p><p>Sample values inside <code>@Schema</code> do not become runtime defaults. They exist for documentation, while Bean Validation annotations still decide which incoming values are accepted by validation. Keeping those responsibilities distinct lets the request model carry both runtime rules and API metadata without confusing the two.</p><p>Metadata can also be placed on the record itself when the whole request model needs extra context:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;9da47ffa-4d36-4102-91c7-ded6d7002072&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">@Schema(description = "Data required to create a book")
public record BookRequest(
        @NotBlank String title,
        @NotBlank String author
) {
}</code></pre></div><p>The record-level annotation applies to the complete schema, while property-level annotations can still add field-specific information. If several endpoints accept <code>BookRequest</code>, Springdoc can reference the same generated schema from each operation rather than repeating the field definitions for every route.</p><p>Nested DTOs follow the same general behavior. If a request contains another record or class, Springdoc can resolve that Java type into a referenced schema and connect it to the parent model. Collections become array schemas from their Java types, while enum properties can expose the permitted enum values in the generated document.</p><p>Fields representing dates, prices, external identifiers, or restricted strings can benefit from extra metadata when the Java type does not communicate enough on its own. Sample values can also make Swagger UI more practical because someone preparing a request can see the expected format before sending it.</p><p>The controller continues to receive the request through Spring MVC in the usual way:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;b6f2c7e0-f854-4bb4-b38a-2fece1a21903&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">@PostMapping
public ResponseEntity&lt;BookResponse&gt; createBook(
        @Valid @RequestBody BookRequest request) {

    BookResponse book = bookService.create(request);
    return ResponseEntity.status(HttpStatus.CREATED).body(book);
}</code></pre></div><p><code>@RequestBody</code> tells Spring MVC to deserialize the incoming JSON into <code>BookRequest</code>, while <code>@Valid</code> starts Bean Validation for that object. Springdoc reads the same method signature and connects the generated request body to the <code>BookRequest</code> schema, so the documentation stays tied to the type accepted by the endpoint.</p><p>With these additions, the OpenAPI document gives readers more than route information alone. They can see what each endpoint does, which responses belong to it, what request data it expects, and how individual fields should be interpreted without moving those details away from the controller and DTO classes that define the API.</p><h3>Conclusion</h3><p>Springdoc takes the controller mappings and Java types already in the application and turns them into OpenAPI documentation that Swagger UI can present in the browser. From there, annotations like <code>@Operation</code>, <code>@ApiResponse</code>, <code>@Parameter</code>, and <code>@Schema</code> let us fill in the details that the code cannot communicate by itself, so the finished docs stay closely connected to the endpoints and request models they explain.</p><ol><li><p><em><a href="iframe.php?url=https%3A%2F%2Fspringdoc.org%2F">Springdoc OpenAPI Documentation</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fswagger.io%2Fspecification%2F">OpenAPI Specification</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fswagger.io%2Fopen-source%2Fswagger-ui%2F">Swagger UI Documentation</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.swagger.io%2Fswagger-core%2Fv2.2.39%2Fapidocs%2Fio%2Fswagger%2Fv3%2Foas%2Fannotations%2Fpackage-summary.html">Swagger OpenAPI Annotations</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&amp;utm_medium=email&amp;utm_content=share&amp;action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2F%3Futm_source%3Dsubstack%26amp%3Butm_medium%3Demail%26amp%3Butm_content%3Dshare%26amp%3Baction%3Dshare"><span>Share Alexander Obregon's Substack</span></a></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21FgSG%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fa3a9c452-2eb0-4288-831a-58f6aaab2fde_276x276.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!FgSG!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa3a9c452-2eb0-4288-831a-58f6aaab2fde_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!FgSG!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa3a9c452-2eb0-4288-831a-58f6aaab2fde_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!FgSG!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa3a9c452-2eb0-4288-831a-58f6aaab2fde_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!FgSG!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa3a9c452-2eb0-4288-831a-58f6aaab2fde_276x276.png 1456w" sizes="auto, 100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21FgSG%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fa3a9c452-2eb0-4288-831a-58f6aaab2fde_276x276.png" width="276" height="276" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/a3a9c452-2eb0-4288-831a-58f6aaab2fde_276x276.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:276,&quot;width&quot;:276,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!FgSG!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa3a9c452-2eb0-4288-831a-58f6aaab2fde_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!FgSG!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa3a9c452-2eb0-4288-831a-58f6aaab2fde_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!FgSG!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa3a9c452-2eb0-4288-831a-58f6aaab2fde_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!FgSG!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa3a9c452-2eb0-4288-831a-58f6aaab2fde_276x276.png 1456w" sizes="auto, 100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Ficons8.com%2Ficon%2F90519%2Fspring-boot">Spring Boot</a> icon by <a href="iframe.php?url=https%3A%2F%2Ficons8.com%2F">Icons8</a></figcaption></figure></div>]]></content:encoded></item><item><title><![CDATA[Weekly Recap – October 4th, 2026]]></title><description><![CDATA[Spring Boot and SQL]]></description><link>https://alexanderobregon.substack.com/p/weekly-recap-october-4th-2026</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/weekly-recap-october-4th-2026</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Mon, 05 Oct 2026 11:57:11 GMT</pubDate><content:encoded><![CDATA[<p>Last week I published two new articles, one on integration testing in Spring Boot with Testcontainers and one on working with percentiles in SQL. The Spring Boot post covers running integration tests against real services in containers, while the SQL post goes through percentile queries for finding values at different points in a dataset.</p><p>If you&#8217;re new or want to know more about what I do, check out my <a href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fabout">About Me page.</a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fsubscribe%3F"><span>Subscribe now</span></a></p><h4><strong>Substack Articles</strong></h4><div class="digest-post-embed" data-attrs="{&quot;nodeId&quot;:&quot;25367ce4-5ac2-4136-be2d-c0ec79c86417&quot;,&quot;caption&quot;:&quot;Unit tests are great for checking individual classes, but database behavior can be harder to test through mocks alone. An in-memory database can help, but PostgreSQL, MySQL, MongoDB, and other databases can behave differently from the database the application runs against outside the test suite. Testcontainers lets integration tests start te&#8230;&quot;,&quot;cta&quot;:null,&quot;showBylines&quot;:true,&quot;showDescription&quot;:true,&quot;showImage&quot;:true,&quot;size&quot;:&quot;sm&quot;,&quot;isEditorNode&quot;:true,&quot;title&quot;:&quot;Spring Boot Integration Testing with Testcontainers&quot;,&quot;publishedBylines&quot;:[{&quot;id&quot;:185785576,&quot;name&quot;:&quot;Alexander Obregon&quot;,&quot;bio&quot;:&quot;I publish on Medium and use Substack to share recaps, exclusive articles, and a way to support my programming-focused content.&quot;,&quot;photo_url&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8905d669-ef92-4534-9c0c-f486ad372c94_1905x1905.jpeg&quot;,&quot;is_guest&quot;:false,&quot;bestseller_tier&quot;:null}],&quot;post_date&quot;:&quot;2026-09-30T00:27:25.159Z&quot;,&quot;cover_image&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/246f8f74-8da0-438b-8979-67683e320808_480x480.png&quot;,&quot;cover_image_alt&quot;:null,&quot;canonical_url&quot;:&quot;https://alexanderobregon.substack.com/p/spring-boot-integration-testing-with&quot;,&quot;section_name&quot;:&quot;Java and JVM&quot;,&quot;video_upload_id&quot;:null,&quot;id&quot;:218098496,&quot;type&quot;:&quot;newsletter&quot;,&quot;reaction_count&quot;:0,&quot;comment_count&quot;:0,&quot;publication_id&quot;:4719466,&quot;publication_name&quot;:&quot;Alexander Obregon's Substack&quot;,&quot;publication_logo_url&quot;:&quot;&quot;,&quot;belowTheFold&quot;:false,&quot;youtube_url&quot;:null,&quot;show_links&quot;:null,&quot;feed_url&quot;:null}"></div><div class="digest-post-embed" data-attrs="{&quot;nodeId&quot;:&quot;0f1511e6-4590-40e3-a8c9-7d778f8748ee&quot;,&quot;caption&quot;:&quot;Percentiles can help explain where values fall across an ordered dataset without relying only on an average. They&#8217;re often applied to salary data, API response times, sales figures, test scores, delivery times, and other numeric data where distribution is important. Instead of looking only at an average salary, you can calculate the 25th, 50&#8230;&quot;,&quot;cta&quot;:null,&quot;showBylines&quot;:true,&quot;showDescription&quot;:true,&quot;showImage&quot;:true,&quot;size&quot;:&quot;sm&quot;,&quot;isEditorNode&quot;:true,&quot;title&quot;:&quot;SQL Percentile Queries&quot;,&quot;publishedBylines&quot;:[{&quot;id&quot;:185785576,&quot;name&quot;:&quot;Alexander Obregon&quot;,&quot;bio&quot;:&quot;I publish on Medium and use Substack to share recaps, exclusive articles, and a way to support my programming-focused content.&quot;,&quot;photo_url&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8905d669-ef92-4534-9c0c-f486ad372c94_1905x1905.jpeg&quot;,&quot;is_guest&quot;:false,&quot;bestseller_tier&quot;:null}],&quot;post_date&quot;:&quot;2026-10-02T16:09:13.932Z&quot;,&quot;cover_image&quot;:&quot;https://substackcdn.com/image/fetch/$s_!ioNC!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F614e27ae-a45f-40eb-b8e7-1832a0b22402_306x306.png&quot;,&quot;cover_image_alt&quot;:null,&quot;canonical_url&quot;:&quot;https://alexanderobregon.substack.com/p/sql-percentile-queries&quot;,&quot;section_name&quot;:&quot;SQL&quot;,&quot;video_upload_id&quot;:null,&quot;id&quot;:218102146,&quot;type&quot;:&quot;newsletter&quot;,&quot;reaction_count&quot;:0,&quot;comment_count&quot;:0,&quot;publication_id&quot;:4719466,&quot;publication_name&quot;:&quot;Alexander Obregon's Substack&quot;,&quot;publication_logo_url&quot;:&quot;&quot;,&quot;belowTheFold&quot;:false,&quot;youtube_url&quot;:null,&quot;show_links&quot;:null,&quot;feed_url&quot;:null}"></div><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2F%3Futm_source%3Dsubstack%26utm_medium%3Demail%26utm_content%3Dshare%26action%3Dshare"><span>Share Alexander Obregon's Substack</span></a></p>]]></content:encoded></item><item><title><![CDATA[SQL Percentile Queries]]></title><description><![CDATA[Percentiles can help explain where values fall across an ordered dataset without relying only on an average.]]></description><link>https://alexanderobregon.substack.com/p/sql-percentile-queries</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/sql-percentile-queries</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Fri, 02 Oct 2026 16:09:13 GMT</pubDate><enclosure url="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21ioNC%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F614e27ae-a45f-40eb-b8e7-1832a0b22402_306x306.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21G-6I%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F393bd0c4-358a-4f15-aa30-def33c6a4d5c_800x373.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!G-6I!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F393bd0c4-358a-4f15-aa30-def33c6a4d5c_800x373.png 424w, https://substackcdn.com/image/fetch/$s_!G-6I!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F393bd0c4-358a-4f15-aa30-def33c6a4d5c_800x373.png 848w, https://substackcdn.com/image/fetch/$s_!G-6I!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F393bd0c4-358a-4f15-aa30-def33c6a4d5c_800x373.png 1272w, https://substackcdn.com/image/fetch/$s_!G-6I!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F393bd0c4-358a-4f15-aa30-def33c6a4d5c_800x373.png 1456w" sizes="100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21G-6I%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F393bd0c4-358a-4f15-aa30-def33c6a4d5c_800x373.png" width="800" height="373" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/393bd0c4-358a-4f15-aa30-def33c6a4d5c_800x373.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:373,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!G-6I!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F393bd0c4-358a-4f15-aa30-def33c6a4d5c_800x373.png 424w, https://substackcdn.com/image/fetch/$s_!G-6I!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F393bd0c4-358a-4f15-aa30-def33c6a4d5c_800x373.png 848w, https://substackcdn.com/image/fetch/$s_!G-6I!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F393bd0c4-358a-4f15-aa30-def33c6a4d5c_800x373.png 1272w, https://substackcdn.com/image/fetch/$s_!G-6I!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F393bd0c4-358a-4f15-aa30-def33c6a4d5c_800x373.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Fcommons.wikimedia.org%2Fwiki%2FFile%3ASql_data_base_with_logo.png">Image Source</a></figcaption></figure></div><p>Percentiles can help explain where values fall across an ordered dataset without relying only on an average. They&#8217;re often applied to salary data, API response times, sales figures, test scores, delivery times, and other numeric data where distribution is important. Instead of looking only at an average salary, you can calculate the 25th, 50th, 75th, or 90th percentile to see how values are spread across the group. SQL provides functions such as <code>PERCENTILE_CONT</code>, <code>PERCENTILE_DISC</code>, and <code>PERCENT_RANK</code> for different types of percentile calculations. Some return the value found at a requested percentile, while others return the relative position of each row within the ordered data. Learning how these calculations differ makes it much more natural to choose the right function for the result you need.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fsubscribe%3F"><span>Subscribe now</span></a></p><h3>Calculating Percentile Values</h3><p>Percentile calculations depend on an ordered set of values because the position of each value determines where a requested percentile falls. We sort the selected column, identify the requested location across that ordered data, then return a value based on the percentile function we chose. This can answer questions such as where the middle salary falls, what response time marks the 95th percentile, or what sales amount represents the upper quarter of a dataset. The same mechanics also apply when the data needs to be broken into categories such as departments, regions, or product groups.</p><h4>Percentiles From Ordered Data</h4><p>Before we calculate a percentile, the values need to be placed in an order because percentiles are based on position rather than the original order of rows in the table. For most salary, sales, and response-time queries, that means arranging the values from lowest to highest so higher percentile numbers move toward the upper end of the distribution.</p><p>We can start with a small employee table that stores annual salaries:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;8077131d-903a-410e-8640-64da05d83b0e&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">CREATE TABLE employees (
    employee_id INT PRIMARY KEY,
    department VARCHAR(50),
    salary DECIMAL(10, 2)
);

INSERT INTO employees (employee_id, department, salary)
VALUES
    (1, 'Engineering', 52000),
    (2, 'Engineering', 61000),
    (3, 'Engineering', 68000),
    (4, 'Engineering', 74000),
    (5, 'Engineering', 83000),
    (6, 'Engineering', 91000),
    (7, 'Engineering', 105000),
    (8, 'Engineering', 118000);</code></pre></div><p>We now have eight salary values available for the percentile calculation, and when we order them from lowest to highest, they are <code>52000</code>, <code>61000</code>, <code>68000</code>, <code>74000</code>, <code>83000</code>, <code>91000</code>, <code>105000</code>, and <code>118000</code>. Their positions within that sequence are what percentile functions rely on, so the calculation is based on where a value falls after ordering rather than where its row happened to appear in the table.</p><p>Percentile functions normally express the requested point as a decimal between <code>0</code> and <code>1</code>, which means <code>0.25</code> represents the 25th percentile, <code>0.50</code> represents the 50th percentile, and <code>0.90</code> represents the 90th percentile. The percentage and decimal refer to the same location, only written in different forms.</p><p>We can calculate the median salary with <code>PERCENTILE_CONT</code> like this in PostgreSQL:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;eaf6a3e0-d6f7-48ab-bef3-a26b8647571c&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    PERCENTILE_CONT(0.50)
    WITHIN GROUP (ORDER BY salary) AS median_salary
FROM employees;</code></pre></div><p>The <code>WITHIN GROUP</code> clause tells the database which ordering belongs to the percentile calculation, while <code>0.50</code> asks for the halfway point across those ordered salary values. Because the table contains eight salaries, there isn&#8217;t a single row directly in the middle, so the center falls between the fourth value of <code>74000</code> and the fifth value of <code>83000</code>.</p><p><code>PERCENTILE_CONT</code> treats the values as part of a continuous distribution and interpolates between those two numbers, producing <code>78500</code>. That result does not need to exist as a stored salary because the function is calculating a point within the distribution rather than selecting one specific row from the table.</p><p>We can change only the percentile argument when we want a different point from the same salary distribution:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;7cc30bd9-ff38-4aad-b83a-e0b96ae201ed&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    PERCENTILE_CONT(0.90)
    WITHIN GROUP (ORDER BY salary) AS p90_salary
FROM employees;</code></pre></div><p>This time we request <code>0.90</code>, which moves the calculation much farther toward the upper end of the salaries. Choosing a lower value such as <code>0.10</code> would move toward the lower end instead, while <code>0.50</code> stays centered, so the decimal passed to <code>PERCENTILE_CONT</code> directly controls which point across the ordered values we want to retrieve.</p><p>Ordering direction changes the meaning of the result as well. Salary and response-time reports normally place smaller values first with ascending order, which makes higher percentile numbers correspond to higher measurements. Reversing the order would reverse that interpretation, so the <code>ORDER BY</code> direction needs to match the way the percentile is meant to be read.</p><p>Null values also need to be accounted for when reading percentile results because ordered-set percentile functions such as PostgreSQL <code>PERCENTILE_CONT</code> ignore null input values instead of placing missing values somewhere inside the numeric distribution. If a salary row contains <code>NULL</code> in the <code>salary</code> column, that row does not receive a position within the ordered salaries used for the calculation.</p><h4>Continuous Versus Discrete Percentiles</h4><p>Two common percentile functions can answer closely related questions while returning different values because they follow different rules when the requested percentile falls between rows. <code>PERCENTILE_CONT</code> treats the ordered data as a continuous distribution and can interpolate between neighboring values, while <code>PERCENTILE_DISC</code> returns the first observed value whose cumulative position reaches or passes the requested percentile.</p><p>We can run the same median calculation from earlier with <code>PERCENTILE_DISC</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;4b82c7ea-9212-46bd-824c-6b948ab4ef16&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    PERCENTILE_DISC(0.50)
    WITHIN GROUP (ORDER BY salary) AS median_salary
FROM employees;</code></pre></div><p>The query still orders the same salaries and still asks for the 50th percentile, but the function changes how that position is resolved. Rather than calculating a value between <code>74000</code> and <code>83000</code>, <code>PERCENTILE_DISC</code> returns an observed salary from the ordered data, which gives us <code>74000</code> for these rows.</p><p>That leaves us with two valid results from the same dataset that represent different interpretations of the midpoint. <code>PERCENTILE_CONT</code> returned <code>78500</code> because it calculated the continuous point between the two middle salaries, while <code>PERCENTILE_DISC</code> returned <code>74000</code> because its result had to come directly from the observed values.</p><p>Continuous percentiles make sense for measurements where values between two recorded observations still carry meaning, such as salary, transaction amount, response duration, temperature, and distance. If neighboring response times are <code>114</code> and <code>127</code> milliseconds, a calculated value somewhere between them can still represent a valid location within the distribution.</p><p>Discrete percentiles are better suited to reports where the returned value needs to come directly from the stored observations rather than from interpolation. Instead of calculating a number between two rows, the database selects an existing value that reaches the requested percentile.</p><p>We can see that difference again with response-time data:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;8bde4ff6-a484-4947-958d-8362fdfe6a66&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">CREATE TABLE api_requests (
    request_id INT PRIMARY KEY,
    response_ms INT
);

INSERT INTO api_requests (request_id, response_ms)
VALUES
    (1, 95),
    (2, 101),
    (3, 108),
    (4, 114),
    (5, 127),
    (6, 145),
    (7, 183),
    (8, 240);</code></pre></div><p>This table gives us eight recorded response times measured in milliseconds, all stored as integers. Those integer values do not require the percentile result to remain an integer when we choose a continuous calculation because interpolation can place the requested percentile between two observations.</p><p>We can request the 95th percentile with <code>PERCENTILE_CONT</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;3410300d-1883-4f86-9501-68f953ad8ccf&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    PERCENTILE_CONT(0.95)
    WITHIN GROUP (ORDER BY response_ms) AS p95_response_ms
FROM api_requests;</code></pre></div><p>The response times are ordered before the 95th percentile position is located, and if that position falls between two stored rows, <code>PERCENTILE_CONT</code> interpolates between them. The returned result can therefore contain a fractional value even though every value in <code>response_ms</code> was stored as a whole number.</p><p>If the report needs the percentile to match one of the recorded response times, we can change the function while keeping the percentile itself the same:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;642bd0c8-7c4a-45b2-8eaf-4f1c463cfd09&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    PERCENTILE_DISC(0.95)
    WITHIN GROUP (ORDER BY response_ms) AS p95_response_ms
FROM api_requests;</code></pre></div><p>We still ask for the 95th percentile, but the result now comes from one of the response times already present in the table. The percentile argument has not changed, so any difference between the two results comes from the continuous and discrete calculation rules rather than from a different requested position.</p><p>Choosing between these functions depends on what the returned number needs to represent. When an interpolated measurement makes sense, <code>PERCENTILE_CONT</code> gives us that point within the distribution, while <code>PERCENTILE_DISC</code> keeps the result tied directly to an observed value.</p><h4>Quartiles From Percentile Functions</h4><p>Quartiles are specific percentile locations that divide ordered values into four portions, with the first quartile often written as <code>Q1</code> at the 25th percentile, the median at the 50th percentile, and the third quartile written as <code>Q3</code> at the 75th percentile. Because each quartile is already a percentile position, we do not need a different family of functions to calculate them.</p><p>We can return all three salary quartiles in one query by changing only the percentile argument passed to <code>PERCENTILE_CONT</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;11f604e4-af49-4957-accd-873d3526362e&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    PERCENTILE_CONT(0.25)
        WITHIN GROUP (ORDER BY salary) AS q1,
    PERCENTILE_CONT(0.50)
        WITHIN GROUP (ORDER BY salary) AS median,
    PERCENTILE_CONT(0.75)
        WITHIN GROUP (ORDER BY salary) AS q3
FROM employees;</code></pre></div><p>All three expressions read from the same salary column and order the values the same way, but each asks for a different location across that ordered distribution. The first expression finds the quarter point, the second finds the midpoint, and the third finds the three-quarter point, giving us three reference values from the same salary data.</p><p>Reading those results side by side gives us more information about how the center of the salary distribution is spread out. If <code>Q1</code>, the median, and <code>Q3</code> fall relatively close to each other, the middle half of the salaries covers a narrower range, while greater distance between those values means that central portion spans a wider range.</p><p>Quartiles can also be paired with percentile positions farther toward the lower or upper portions of the data when a report needs more detail. We can use a small sales table for the sales examples that follow:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;fca55789-d16c-4896-9d22-8e0de44ae384&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">CREATE TABLE sales (
    sale_id INT PRIMARY KEY,
    region VARCHAR(50),
    amount DECIMAL(10, 2),
    sale_date DATE
);

INSERT INTO sales (sale_id, region, amount, sale_date)
VALUES
    (1, 'North', 120, DATE '2026-01-10'),
    (2, 'North', 180, DATE '2026-02-14'),
    (3, 'North', 180, DATE '2026-03-03'),
    (4, 'North', 260, DATE '2026-04-20'),
    (5, 'South', 90, DATE '2025-12-18'),
    (6, 'South', 140, DATE '2026-01-25'),
    (7, 'South', 220, DATE '2026-03-12'),
    (8, 'South', 310, DATE '2026-05-06');</code></pre></div><p>Sales figures can then be examined at the 10th, 25th, 50th, 75th, and 90th percentiles in the same query:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;b66ebc5d-9321-4f9f-b124-5fb96ae8c8a9&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    PERCENTILE_CONT(0.10)
        WITHIN GROUP (ORDER BY amount) AS p10,
    PERCENTILE_CONT(0.25)
        WITHIN GROUP (ORDER BY amount) AS p25,
    PERCENTILE_CONT(0.50)
        WITHIN GROUP (ORDER BY amount) AS p50,
    PERCENTILE_CONT(0.75)
        WITHIN GROUP (ORDER BY amount) AS p75,
    PERCENTILE_CONT(0.90)
        WITHIN GROUP (ORDER BY amount) AS p90
FROM sales;</code></pre></div><p>We now get several reference points from the same ordered set of sales amounts, with <code>p50</code> representing the median and <code>p25</code> and <code>p75</code> matching <code>Q1</code> and <code>Q3</code>. The added <code>p10</code> and <code>p90</code> values extend farther toward the lower and upper ends, which gives the report more detail about how sales amounts are distributed beyond the middle half.</p><p>Quartiles also give us the values needed for the interquartile range, which is calculated as <code>Q3 - Q1</code> and represents the span covered by the middle 50 percent of the ordered values. We can calculate that distance directly from the salary data:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;2a44998a-e293-4c63-8eca-ce51fecd2a97&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    PERCENTILE_CONT(0.75)
        WITHIN GROUP (ORDER BY salary)
    -
    PERCENTILE_CONT(0.25)
        WITHIN GROUP (ORDER BY salary) AS interquartile_range
FROM employees;</code></pre></div><p>Both percentile expressions are calculated from the same ordered salary column, and subtracting the 25th percentile from the 75th percentile leaves the distance between those two quartile boundaries. Because the calculation focuses on the middle half of the data, the smallest and largest salaries do not directly determine its value, which makes the interquartile range different from the full range calculated from the minimum and maximum.</p><h4>Percentiles Within Groups</h4><p>Reports frequently need percentile values for individual categories rather than one calculation across the entire table, so salary data can be grouped by department, sales can be grouped by region, and response times can be grouped by endpoint. Each category then gets its own ordered distribution, which lets us calculate a percentile from only the rows that belong to that group.</p><p>With PostgreSQL ordered-set aggregates, we can pair <code>PERCENTILE_CONT</code> with <code>GROUP BY</code> to calculate a median for every department:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;54b73e32-b177-4d85-bc46-dc275338feaf&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    department,
    PERCENTILE_CONT(0.50)
        WITHIN GROUP (ORDER BY salary) AS median_salary
FROM employees
GROUP BY department;</code></pre></div><p>The rows are grouped by <code>department</code>, then the salaries within each department are ordered for the percentile calculation. Engineering salaries therefore have no effect on the median returned for Finance, Sales, or any other department because every group is evaluated from its own rows.</p><p>That distinction becomes important when categories have very different value ranges. An organization-wide salary median can answer a broad question about all employees, while department-level calculations let us see how the midpoint changes across departments without combining every salary into the same distribution.</p><p>The same structure can be applied to regional sales figures when we want the 90th percentile calculated independently for every region:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;81b73f0b-39fe-4de1-87a7-25a61626ec78&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    region,
    PERCENTILE_CONT(0.90)
        WITHIN GROUP (ORDER BY amount) AS p90_sale
FROM sales
GROUP BY region;</code></pre></div><p>Each returned row represents a region and its own 90th percentile sales amount, so transactions from one region do not affect the percentile calculated for another. We first divide the rows through <code>GROUP BY</code>, then the percentile expression evaluates the ordered <code>amount</code> values within each resulting group.</p><p>Several percentile values can also be returned for every group rather than limiting each category to a single point. A regional report can return <code>Q1</code>, the median, and <code>Q3</code> side by side:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;7d1888cb-16eb-46ab-aa66-83dd8f52608d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    region,
    PERCENTILE_CONT(0.25)
        WITHIN GROUP (ORDER BY amount) AS q1,
    PERCENTILE_CONT(0.50)
        WITHIN GROUP (ORDER BY amount) AS median,
    PERCENTILE_CONT(0.75)
        WITHIN GROUP (ORDER BY amount) AS q3
FROM sales
GROUP BY region;</code></pre></div><p>For every region, we now get three points from its own ordered sales distribution, which gives us the quarter point, midpoint, and three-quarter point without combining sales from unrelated regions. That makes comparisons between groups more informative because every set of percentile values comes from the data belonging to that category.</p><p>Filtering changes the population that participates in the calculation as well, because rows that fail a <code>WHERE</code> condition are removed before the grouping and percentile calculation take place. We could limit the sales report to transactions from January 1, 2026 onward like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;fbc662b4-33ce-4fc6-9b6c-b70ee5db53b0&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    region,
    PERCENTILE_CONT(0.90)
        WITHIN GROUP (ORDER BY amount) AS p90_sale
FROM sales
WHERE sale_date &gt;= DATE '2026-01-01'
GROUP BY region;</code></pre></div><p>With the date condition applied first, only sales from January 1, 2026 or later participate in the grouped calculation, after which we divide those remaining rows by region, order the amounts within every group, and calculate the 90th percentile from that filtered set rather than from every sale stored in the table.</p><p>That order affects how we read the result because the returned percentile represents only the rows that passed the filter. Changing the date range, region criteria, or any other condition can therefore change the percentile values even when the percentile expression itself remains unchanged.</p><p>Syntax for percentile calculations varies across database products. PostgreSQL exposes <code>PERCENTILE_CONT</code> and <code>PERCENTILE_DISC</code> as ordered-set aggregate functions, which lets them pair directly with <code>GROUP BY</code>, while SQL Server provides these percentile functions through window syntax and Oracle supports aggregate and analytic forms. Across those database engines, the same core calculation remains in place as values are ordered, a percentile position is requested, and the result comes from the rows included in the relevant dataset or group.</p><h3>Finding Percentile Position</h3><p>Percentile values answer questions about where a number falls within an ordered distribution, while percentile rank looks at the rows already present and tells us where each of those rows falls relative to the others. Instead of asking for the salary at the 90th percentile, we may want to know how every salary compares with the rest of the salaries in the same result. <code>PERCENT_RANK</code> handles that type of calculation by assigning each row a relative position based on its rank within the ordered data.</p><h4>Ranking Every Row</h4><p>Relative ranking begins by ordering the rows according to the value we want to compare, then <code>PERCENT_RANK</code> calculates where each row falls within that ordering. The returned values range from <code>0</code> through <code>1</code>, which gives us a normalized rank rather than a raw row number.</p><p>For the employee salary data from earlier, we can rank every salary from lowest to highest like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;e5246ec0-548e-4c68-86dc-e875b0e47f77&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    employee_id,
    salary,
    PERCENT_RANK() OVER (ORDER BY salary) AS salary_percent_rank
FROM employees;</code></pre></div><p>The <code>OVER</code> clause defines the rows that participate in the calculation, while <code>ORDER BY salary</code> places those rows in salary order before their relative ranks are assigned. We receive a result for every employee, so the query gives us row-by-row positions rather than a single percentile value for the entire table.</p><p><code>PERCENT_RANK</code> bases its result on the row rank and the total number of rows in the ordered set. The formula is:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;c7310e78-c093-4eff-be84-ecf14ab94d07&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">(rank - 1) / (rows - 1)</code></pre></div><p>From that formula, we can see why the first ranked row receives <code>0</code>. Its rank is <code>1</code>, so subtracting <code>1</code> leaves <code>0</code> before the division takes place, while rows farther through the ordered set receive larger values as their rank increases.</p><p>With eight employees, a row ranked sixth receives a percentile rank of about <code>0.7143</code>, which corresponds to roughly 71.43 percent when multiplied by <code>100</code>. We can perform that conversion directly in the query:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;325e9486-93db-4578-a07d-e2c3f85d542c&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    employee_id,
    salary,
    PERCENT_RANK() OVER (ORDER BY salary) * 100 AS salary_percent_rank
FROM employees;</code></pre></div><p>The ranking calculation stays the same, and multiplying by <code>100</code> only changes how we present the result. Instead of reading <code>0.7143</code>, we can read about <code>71.43</code>, which is usually more familiar when discussing percentile rank as a percentage.</p><p>This type of result is different from asking for a percentile value. <code>PERCENTILE_CONT(0.90)</code> returns the salary located at the 90th percentile, while <code>PERCENT_RANK()</code> tells us the relative position assigned to every salary already present in the result. We can think about those questions in opposite directions. With <code>PERCENTILE_CONT</code>, we provide a percentile and receive a value back. With <code>PERCENT_RANK</code>, the rows already exist, and we calculate a relative position for each of them.</p><p>Tied values affect the ranking because <code>PERCENT_RANK</code> follows the same ranking behavior as <code>RANK</code>. Rows with equal ordering values receive the same rank, so they also receive the same percentile rank.</p><p>We can see that more easily by displaying <code>RANK()</code> beside <code>PERCENT_RANK()</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;498c7283-c458-4310-b5b6-5bc651c4eccd&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    sale_id,
    amount,
    RANK() OVER (ORDER BY amount) AS sale_rank,
    PERCENT_RANK() OVER (ORDER BY amount) AS sale_percent_rank
FROM sales;</code></pre></div><p>If two sales share the same <code>amount</code>, both receive the same rank and the same percentile rank. The next distinct amount can then jump ahead because <code>RANK()</code> leaves a gap after tied rows, and <code>PERCENT_RANK()</code> reflects that rank when calculating the relative position.</p><p>Partitioning lets us calculate those positions inside categories rather than across the entire result. Salary data can be ranked within each department with <code>PARTITION BY</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;8ed81bd7-baeb-4a17-9d55-c836fd2bdcab&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    department,
    employee_id,
    salary,
    PERCENT_RANK() OVER (
        PARTITION BY department
        ORDER BY salary
    ) AS department_percent_rank
FROM employees;</code></pre></div><p>Each department receives its own ranking sequence because <code>PARTITION BY department</code> divides the rows into department groups before the ranking calculation is applied. We then order salaries inside each department, so employees are compared only with coworkers from that department.</p><p>Because the ranking restarts for every partition, the same salary can receive different percentile ranks across different departments. A salary near the top of one department could fall closer to the middle of another if the surrounding salaries are different. The same idea applies to response times and sales data. Requests can be ranked within an endpoint, sales can be ranked within a region, and transaction amounts can be ranked within a customer category. Partitioning changes the population that each row is compared against while leaving the <code>PERCENT_RANK</code> formula itself unchanged.</p><p>Ordering direction deserves attention as well. With <code>ORDER BY salary</code>, lower salaries receive lower percentile ranks and higher salaries move toward <code>1</code>. Reversing the order to <code>ORDER BY salary DESC</code> changes that interpretation because the highest salary becomes the first ranked row and receives <code>0</code>.</p><p>For salary and response-time reporting, ascending order usually matches the familiar reading where larger values appear farther up the percentile scale. If the business question calls for the opposite direction, descending order can still be valid, but the returned ranks have to be read with that reversed ordering in mind.</p><h4>Choosing the Calculation</h4><p>Picking the right percentile function becomes much more natural when we start with the result we need. If we want the salary value at the 75th percentile, we are asking for a point from the distribution. If we want to know where each employee salary falls compared with the rest, we are asking for a relative position. <code>PERCENTILE_CONT</code> and <code>PERCENTILE_DISC</code> belong to the first case because we provide a percentile position and receive a value. <code>PERCENT_RANK</code> belongs to the second because it evaluates the existing rows and returns a relative rank for each row in the result.</p><p>We can see the difference with response-time data. If we want the response time at the 95th percentile, the query asks for a percentile value:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;8c9ab595-3ac6-4436-b35b-c9479bbbf7e6&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    PERCENTILE_CONT(0.95)
    WITHIN GROUP (ORDER BY response_ms) AS p95_response_ms
FROM api_requests;</code></pre></div><p>The result contains a single value representing the requested point across the ordered response times. We are not ranking every request individually, so the query returns the percentile measurement itself rather than a row-by-row comparison.</p><p>If the question changes and we want to see where every request falls compared with the rest, <code>PERCENT_RANK</code> matches that need:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;370e0b11-acaa-448e-a1a5-9d99c1796423&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    request_id,
    response_ms,
    PERCENT_RANK() OVER (ORDER BY response_ms) AS response_percent_rank
FROM api_requests;</code></pre></div><p>Every request now receives its own relative position. Faster responses appear nearer the lower end when <code>response_ms</code> is ordered ascending, while slower responses move closer to the upper end.</p><p>That difference becomes important when reading labels such as p50, p90, p95, and p99. Those names refer to percentile points within an ordered distribution, so a p95 response time answers a question about the response-time value reached at the 95th percentile. Percentile rank attached to an individual request answers a different question by telling us how that request compares with the other rows.</p><p>Sales data follows the same logic. If we want the sales amount located at the 90th percentile, we calculate a percentile value. If we want every sale assigned a relative position, we calculate a percentile rank for each sale.</p><p>We can also apply that comparison within categories. The following query ranks each sale only against sales from the same region:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;d3fdafcf-6de8-49e7-a906-a56f4e591bd3&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    region,
    sale_id,
    amount,
    PERCENT_RANK() OVER (
        PARTITION BY region
        ORDER BY amount
    ) AS regional_percent_rank
FROM sales;</code></pre></div><p>Each returned row now carries a percentile rank based only on its region. We divide the rows by <code>region</code>, order the sales amounts inside each partition, then calculate the relative position from that smaller group rather than from the full sales table.</p><p>Percentile rank also behaves differently when the group contains very few rows. Because the formula depends on both rank and row count, smaller groups produce larger jumps between neighboring percentile-rank values, while larger groups give us more rank positions between the bottom and top.</p><p>Groups containing a single row need special handling because the standard formula would place <code>0</code> in the denominator. SQL implementations define that case directly, and commonly supported implementations return <code>0</code> for the only row in the group. Tied values also influence how we interpret the result because equal values share the same rank. If several sales have the same amount, they receive the same percentile rank rather than receiving slightly different percentages based only on their physical row positions.</p><p>Taken as a whole, these functions answer related but distinct questions. <code>PERCENTILE_CONT</code> returns an interpolated value at a requested percentile, <code>PERCENTILE_DISC</code> returns an observed value from that percentile position, and <code>PERCENT_RANK</code> tells us where each existing row falls relative to the other rows in its ordered group.</p><h3>Conclusion</h3><p>Percentile queries come down to ordering values, choosing the percentile point we need, and deciding what type of result should come back. <code>PERCENTILE_CONT</code> can interpolate between values, <code>PERCENTILE_DISC</code> returns an observed value, and <code>PERCENT_RANK</code> gives each row a relative position within its ordered group. Those mechanics let us examine salary, response-time, sales, and similar data across different points in a distribution rather than relying on a single average.</p><ol><li><p><em><a href="iframe.php?url=https%3A%2F%2Fwww.postgresql.org%2Fdocs%2Fcurrent%2Ffunctions-aggregate.html">PostgreSQL Aggregate Functions</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fwww.postgresql.org%2Fdocs%2Fcurrent%2Ffunctions-window.html">PostgreSQL Window Functions</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Flearn.microsoft.com%2Fen-us%2Fsql%2Ft-sql%2Ffunctions%2Fpercentile-cont-transact-sql%3Fview%3Dsql-server-ver17">SQL Server PERCENTILE_CONT</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Flearn.microsoft.com%2Fen-us%2Fsql%2Ft-sql%2Ffunctions%2Fpercent-rank-transact-sql%3Fview%3Dsql-server-ver17">SQL Server PERCENT_RANK</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2F%3Futm_source%3Dsubstack%26utm_medium%3Demail%26utm_content%3Dshare%26action%3Dshare"><span>Share Alexander Obregon's Substack</span></a></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21ioNC%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F614e27ae-a45f-40eb-b8e7-1832a0b22402_306x306.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!ioNC!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F614e27ae-a45f-40eb-b8e7-1832a0b22402_306x306.png 424w, https://substackcdn.com/image/fetch/$s_!ioNC!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F614e27ae-a45f-40eb-b8e7-1832a0b22402_306x306.png 848w, https://substackcdn.com/image/fetch/$s_!ioNC!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F614e27ae-a45f-40eb-b8e7-1832a0b22402_306x306.png 1272w, https://substackcdn.com/image/fetch/$s_!ioNC!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F614e27ae-a45f-40eb-b8e7-1832a0b22402_306x306.png 1456w" sizes="auto, 100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21ioNC%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F614e27ae-a45f-40eb-b8e7-1832a0b22402_306x306.png" width="306" height="306" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/614e27ae-a45f-40eb-b8e7-1832a0b22402_306x306.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:306,&quot;width&quot;:306,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!ioNC!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F614e27ae-a45f-40eb-b8e7-1832a0b22402_306x306.png 424w, https://substackcdn.com/image/fetch/$s_!ioNC!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F614e27ae-a45f-40eb-b8e7-1832a0b22402_306x306.png 848w, https://substackcdn.com/image/fetch/$s_!ioNC!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F614e27ae-a45f-40eb-b8e7-1832a0b22402_306x306.png 1272w, https://substackcdn.com/image/fetch/$s_!ioNC!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F614e27ae-a45f-40eb-b8e7-1832a0b22402_306x306.png 1456w" sizes="auto, 100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Ffreesvg.org%2Fmono-sql">Image Source</a></figcaption></figure></div>]]></content:encoded></item><item><title><![CDATA[Spring Boot Integration Testing with Testcontainers]]></title><description><![CDATA[Unit tests are great for checking individual classes, but database behavior can be harder to test through mocks alone.]]></description><link>https://alexanderobregon.substack.com/p/spring-boot-integration-testing-with</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/spring-boot-integration-testing-with</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Wed, 30 Sep 2026 00:27:25 GMT</pubDate><enclosure url="iframe.php?url=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F246f8f74-8da0-438b-8979-67683e320808_480x480.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21CE3j%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fa8c2efde-806b-4d76-9f2e-e4500b88505e_800x444.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!CE3j!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa8c2efde-806b-4d76-9f2e-e4500b88505e_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!CE3j!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa8c2efde-806b-4d76-9f2e-e4500b88505e_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!CE3j!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa8c2efde-806b-4d76-9f2e-e4500b88505e_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!CE3j!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa8c2efde-806b-4d76-9f2e-e4500b88505e_800x444.jpeg 1456w" sizes="100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21CE3j%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fa8c2efde-806b-4d76-9f2e-e4500b88505e_800x444.jpeg" width="800" height="444" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/a8c2efde-806b-4d76-9f2e-e4500b88505e_800x444.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:444,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!CE3j!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa8c2efde-806b-4d76-9f2e-e4500b88505e_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!CE3j!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa8c2efde-806b-4d76-9f2e-e4500b88505e_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!CE3j!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa8c2efde-806b-4d76-9f2e-e4500b88505e_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!CE3j!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa8c2efde-806b-4d76-9f2e-e4500b88505e_800x444.jpeg 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Fspring.io%2Fprojects%2Fspring-boot">Image Source</a></figcaption></figure></div><p>Unit tests are great for checking individual classes, but database behavior can be harder to test through mocks alone. An in-memory database can help, but PostgreSQL, MySQL, MongoDB, and other databases can behave differently from the database the application runs against outside the test suite. Testcontainers lets integration tests start temporary containerized databases for the duration of the test run, then remove them when testing finishes. Spring Boot can connect supported containers through <code>@ServiceConnection</code>, letting repository and service tests run against the same database technology the application depends on without keeping a dedicated test database running.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fsubscribe%3F"><span>Subscribe now</span></a></p><h3>Building the Test Environment</h3><p>Integration testing gets more valuable when the database involved in the test behaves like the database the application depends on outside the test suite. Testcontainers fills that gap by starting short-lived services inside containers for the duration of testing, which gives Spring Boot a database it can connect to rather than a mocked repository or an unrelated in-memory engine. The container can come from a normal database image, receive temporary connection details, and disappear after testing finishes. That keeps database-dependent tests self-contained while still exercising behavior that mocks cannot reproduce.</p><h4>Why Containers Belong in Integration Tests</h4><p>Mocks are good for checking logic inside a class because they let us control what a dependency returns. Repository behavior goes further than that. JPA mappings, generated SQL, database constraints, column types, indexes, native queries, transactions, and vendor-specific SQL all depend on the database engine receiving the request. Replacing that database with a mock removes those pieces from the test completely.</p><p>An in-memory database goes a step further because it executes SQL and stores rows, so it can help with fast database-related tests. The limitation comes from differences between database engines. H2, PostgreSQL, and MySQL do not behave identically, and the differences can show up in SQL syntax, data types, identity generation, JSON support, locking rules, indexes, and built-in functions. Passing a test against an in-memory database confirms behavior against that engine, not automatically against the database chosen for the application.</p><p>PostgreSQL-specific SQL gives us a good example of where that distinction becomes relevant:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;9fe524c6-1ed2-48d6-a4a3-621787226a80&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT id, metadata -&gt;&gt; 'author' AS author
FROM books
WHERE title ILIKE '%spring%';</code></pre></div><p>The <code>-&gt;&gt;</code> operator reads a JSON value as text, while <code>ILIKE</code> performs case-insensitive matching in PostgreSQL. If our repository depends on SQL like this, testing it against PostgreSQL gives us feedback from the same database technology that will process the query later. An in-memory database could reject the syntax, interpret it differently, or lack the same feature entirely.</p><p>Testcontainers handles this by starting the requested database image inside a container. Our test still communicates through the normal database driver and Spring data access stack, while the database process exists only for the testing lifecycle. Depending on the container lifecycle we choose later, the same container can remain available across a Spring test context before it is stopped. Host ports are assigned dynamically rather than forcing every PostgreSQL test to claim port <code>5432</code>. That helps when PostgreSQL is already running locally and also prevents multiple test environments from competing for the same host port. Testcontainers keeps track of the assigned port and connection information, so our test configuration can receive the actual address instead of assuming a fixed value.</p><p>The first test run can download the requested container image if it is not already stored locally. Later runs can start from the locally available image, which avoids downloading it every time. Testcontainers still needs access to a supported container runtime, so the developer machine or CI runner must provide an environment where the requested containers can start.</p><p>Containers do not replace unit tests. Logic that has no dependency on database behavior can still be tested quickly with normal objects or mocks. Container-backed integration tests become more relevant around repository mappings, custom queries, schema migrations, transaction behavior, database constraints, and service methods whose behavior depends heavily on persisted data.</p><p>Spring Boot adds service connection support on top of Testcontainers. When we mark a supported container with <code>@ServiceConnection</code>, Spring Boot can create connection details for that service and pass them into the matching auto-configuration. This removes the need to manually copy a generated host, mapped port, username, or password into test properties. The actual container declaration comes later, but the important idea at this stage is that Spring Boot can receive those temporary connection details through its normal test configuration.</p><h4>Add the Test Dependencies</h4><p>Before PostgreSQL can become part of the test suite, the project needs dependencies for normal database access and dependencies that exist only during testing. The application still needs Spring Data JPA and the PostgreSQL JDBC driver, while the test classpath gets Spring Boot JPA testing support, Spring Boot Testcontainers integration, and the PostgreSQL Testcontainers module.</p><p>With Maven and Spring Boot dependency management in place, individual version numbers can stay out of these dependency declarations:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;html&quot;,&quot;nodeId&quot;:&quot;da73fe9e-477a-4080-9bb7-23899e356899&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-html">&lt;dependencies&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;org.postgresql&lt;/groupId&gt;
        &lt;artifactId&gt;postgresql&lt;/artifactId&gt;
        &lt;scope&gt;runtime&lt;/scope&gt;
    &lt;/dependency&gt;

    &lt;dependency&gt;
        &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
        &lt;artifactId&gt;spring-boot-starter-data-jpa-test&lt;/artifactId&gt;
        &lt;scope&gt;test&lt;/scope&gt;
    &lt;/dependency&gt;

    &lt;dependency&gt;
        &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
        &lt;artifactId&gt;spring-boot-testcontainers&lt;/artifactId&gt;
        &lt;scope&gt;test&lt;/scope&gt;
    &lt;/dependency&gt;

    &lt;dependency&gt;
        &lt;groupId&gt;org.testcontainers&lt;/groupId&gt;
        &lt;artifactId&gt;testcontainers-postgresql&lt;/artifactId&gt;
        &lt;scope&gt;test&lt;/scope&gt;
    &lt;/dependency&gt;
&lt;/dependencies&gt;</code></pre></div><p><code>spring-boot-starter-data-jpa</code> provides Spring Data JPA and Hibernate for the application. The PostgreSQL dependency supplies the JDBC driver that allows Java code to communicate with PostgreSQL. Testcontainers does not replace that driver because the container module is responsible for starting PostgreSQL, while the JDBC driver is responsible for the database connection itself.</p><p><code>spring-boot-starter-data-jpa-test</code> provides the Spring Boot 4 test support intended for JPA-focused testing. It also brings in the general Spring testing pieces needed for JUnit Jupiter, assertions, and Spring test contexts. Keeping that dependency in test scope means those libraries stay limited to testing rather than becoming part of the normal application runtime.</p><p><code>spring-boot-testcontainers</code> adds the Spring Boot integration needed for features such as <code>@ServiceConnection</code>. The <code>testcontainers-postgresql</code> dependency adds PostgreSQL-specific Testcontainers support, including the current <code>PostgreSQLContainer</code> class that we will configure later.</p><p>Gradle projects can declare the same dependencies with their matching configurations:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;c0f4dcea-055a-48e4-9d80-2089bb9b15eb&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'org.postgresql:postgresql'

    testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
    testImplementation 'org.springframework.boot:spring-boot-testcontainers'
    testImplementation 'org.testcontainers:testcontainers-postgresql'
}</code></pre></div><p><code>implementation</code> keeps Spring Data JPA available to the application code, while <code>runtimeOnly</code> places the PostgreSQL driver on the runtime classpath without treating it as an API dependency. The three <code>testImplementation</code> entries are available during test compilation and execution, which is where Spring Boot testing support and Testcontainers belong.</p><p>The PostgreSQL module name deserves attention when reading older tutorials. Testcontainers 2 gives PostgreSQL support through the <code>testcontainers-postgresql</code> artifact, and the current container class lives in <code>org.testcontainers.postgresql.PostgreSQLContainer</code>. Older material can still contain <code>org.testcontainers.containers.PostgreSQLContainer</code>, but that class belongs to the older package location and is deprecated in Testcontainers 2.</p><p>No JUnit Testcontainers extension is required for the Spring-managed container bean style covered later. Spring Boot can manage container beans through the application context, including starting them before dependent beans need their connection information and stopping them when that context closes. Projects that choose the JUnit <code>@Testcontainers</code> and <code>@Container</code> style can add <code>testcontainers-junit-jupiter</code>, though that follows a different lifecycle model from the Spring-managed configuration we will build next.</p><p>Keeping the Testcontainers dependencies in test scope also prevents the container libraries from becoming part of normal application startup. The application retains Spring Data JPA and the PostgreSQL driver it already needs, while container management and Spring Boot Testcontainers integration remain limited to the test classpath.</p><h3>Testing the Application Against PostgreSQL</h3><p>With the project dependencies in place, we can connect the application layer to a temporary PostgreSQL database and follow the request from Java code through persistence and back again. The production classes stay unaware of Testcontainers because container configuration belongs on the test side of the project. Spring Boot receives temporary connection details through the test context, while the entity, repository, and service keep the same responsibilities they have during normal application startup.</p><h4>Create a Small Data Layer</h4><p>We can begin with a compact JPA entity so the database behavior stays easy to follow. <code>Book</code> represents a row in the <code>books</code> table, with an automatically generated identifier and a required title. Nothing in the entity refers to Testcontainers, PostgreSQL credentials, or a test-specific connection because those details belong outside the domain class.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;0cd9395f-c7c0-4068-aa25-49d48ca6594f&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.books;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "books")
public class Book {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    protected Book() {
    }

    public Book(String title) {
        this.title = title;
    }

    public Long getId() {
        return id;
    }

    public String getTitle() {
        return title;
    }
}</code></pre></div><p>JPA needs a public or protected no-argument constructor so the persistence provider can create entity instances when rows come back from the database. The second constructor gives application code a direct way to create a book from a title, while <code>GenerationType.IDENTITY</code> leaves identifier generation to PostgreSQL. With schema generation active during these tests, <code>@Column(nullable = false)</code> also contributes a non-null constraint for the <code>title</code> column.</p><p>The repository can stay small because Spring Data JPA can derive the query from the method name:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;9018979c-323a-4f40-a890-e2fef9f29651&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.books;

import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;

public interface BookRepository extends JpaRepository&lt;Book, Long&gt; {

    Optional&lt;Book&gt; findByTitle(String title);
}</code></pre></div><p>Spring Data reads <code>findByTitle</code> from the repository interface and creates a query based on the <code>title</code> property from <code>Book</code>. Returning <code>Optional&lt;Book&gt;</code> gives the caller an explicit result when PostgreSQL has no matching row, rather than passing a <code>null</code> value back to the service layer. That return type also pairs naturally with the service method we will add next.</p><p>The service adds application logic above the repository. In this case, we ask the repository for a title and throw an exception when no matching book exists:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;2a33c303-a460-44bd-b6d2-7cbc1ca4ae59&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.books;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
public class BookService {

    private final BookRepository bookRepository;

    public BookService(BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }

    public Book getByTitle(String title) {
        return bookRepository.findByTitle(title)
                .orElseThrow(() -&gt; new IllegalArgumentException("Book not found"));
    }
}</code></pre></div><p>Constructor injection gives <code>BookService</code> access to the repository through Spring, while <code>@Transactional(readOnly = true)</code> places its read operations inside a read-only transaction when transaction management is active. The service does not need database credentials, container details, or a JDBC URL. Those concerns remain in test configuration, which lets this class behave the same during normal startup and integration testing.</p><h4>Start PostgreSQL for Tests</h4><p>Container configuration belongs under <code>src/test/java</code> because it exists only for testing. We can declare PostgreSQL as a Spring bean inside a class marked with <code>@TestConfiguration</code>, then place <code>@ServiceConnection</code> on the bean method so Spring Boot can derive connection details from the typed PostgreSQL container:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;5620c1a9-5a04-4d2f-b449-992e3558bb4c&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.books;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.postgresql.PostgreSQLContainer;

@TestConfiguration(proxyBeanMethods = false)
public class PostgresTestConfiguration {

    @Bean
    @ServiceConnection
    PostgreSQLContainer postgresContainer() {
        return new PostgreSQLContainer("postgres:17-alpine")
                .withDatabaseName("books_test")
                .withUsername("test")
                .withPassword("test");
    }
}</code></pre></div><p><code>PostgreSQLContainer</code> starts PostgreSQL from the requested image and exposes connection information after the container is running. The database name and credentials belong only to this temporary instance, so they do not need to match production values. Testcontainers maps PostgreSQL to an available host port rather than requiring port <code>5432</code> on the machine running the tests, which lets the temporary database start without depending on a fixed local port.</p><p><code>@ServiceConnection</code> links the container with Spring Boot database auto-configuration. Because the bean method returns the typed <code>PostgreSQLContainer</code>, Spring Boot can identify the service and create JDBC connection details for it. JPA then receives a <code>DataSource</code> backed by the container without a manually assembled <code>spring.datasource.url</code>, host name, or mapped port. Service connection details take precedence over normal connection properties for the matching service.</p><p>Marking the class with <code>@TestConfiguration</code> keeps it on the test side of the project rather than regular application component scanning. The <code>proxyBeanMethods = false</code> setting tells Spring that this configuration does not need method interception for calls between bean methods. Later test classes can bring the configuration into their context through <code>@Import(PostgresTestConfiguration.class)</code>.</p><p>The container bean follows the lifecycle of the Spring test context. Spring Boot starts container beans early enough for dependent connection details to become available, then stops them when that context closes. No manual <code>start()</code> or <code>stop()</code> calls are needed for the Spring-managed bean style shown here.</p><h4>Test the Repository</h4><p>Repository tests can stay focused on JPA rather than loading the full application context. <code>@DataJpaTest</code> brings in entity scanning, Spring Data JPA repositories, Hibernate, JDBC support, and the related test auto-configuration, while regular application components such as <code>BookService</code> are left out of this slice by default. Spring Boot also runs JPA tests inside transactions and rolls them back after each test method.</p><p>We can import the PostgreSQL configuration and let Hibernate create the schema for this compact example:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;c1951443-b506-44a7-b7ed-32ff27e1351d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.books;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;

@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop")
@Import(PostgresTestConfiguration.class)
class BookRepositoryTest {

    @Autowired
    private BookRepository bookRepository;

    @Test
    void findsBookByTitle() {
        Book savedBook = bookRepository.saveAndFlush(
                new Book("Spring Testing")
        );

        Book foundBook = bookRepository.findByTitle("Spring Testing")
                .orElseThrow();

        assertThat(foundBook.getId()).isEqualTo(savedBook.getId());
        assertThat(foundBook.getTitle()).isEqualTo("Spring Testing");
    }
}</code></pre></div><p><code>@Import</code> adds the test configuration, which brings the PostgreSQL container bean into this test context. The <code>ddl-auto=create-drop</code> property tells Hibernate to create the table from the entity mapping for this example and remove the generated schema when the persistence context closes. Projects that manage schema changes through Flyway or Liquibase can let those migrations prepare the container database instead.</p><p>Inside the test method, <code>saveAndFlush()</code> persists the new <code>Book</code> and flushes pending SQL to PostgreSQL before the lookup runs. <code>findByTitle()</code> then sends the repository query to PostgreSQL, and the assertions confirm that the row returned through JPA carries the generated identifier and expected title. This verifies more than a mocked repository could because Hibernate produces SQL, PostgreSQL processes it, and Spring Data maps the result back into the entity.</p><p>Spring Boot 4 treats a database supplied through <code>@ServiceConnection</code> as a test database for test database replacement. The default replacement mode is <code>NON_TEST</code>, so this container-backed <code>DataSource</code> remains in place rather than being replaced by an embedded database. No <code>@AutoConfigureTestDatabase(replace = NONE)</code> annotation is needed for this configuration.</p><p>The transactional behavior of <code>@DataJpaTest</code> also prevents repository tests from leaving committed rows behind. Each method can prepare the records it needs, execute repository calls, and finish with the transaction rolled back, which keeps later methods from inheriting data created by an earlier test. That isolation becomes more important as the repository test class grows and different methods insert records with different values.</p><h4>Test the Service</h4><p>Service-level integration testing loads more of the Spring Boot context because we now want Spring to create <code>BookService</code> along with the repository, Hibernate, transaction management, and the PostgreSQL connection. <code>@SpringBootTest</code> loads the application context rather than the narrower JPA slice, while the same test configuration can supply the temporary PostgreSQL instance.</p><p>We can store a book through the repository and then retrieve it through the service:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;cced4d79-a390-4582-b5c9-5eacaa86ba3d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.books;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.transaction.annotation.Transactional;

@SpringBootTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop")
@Import(PostgresTestConfiguration.class)
@Transactional
class BookServiceIntegrationTest {

    @Autowired
    private BookRepository bookRepository;

    @Autowired
    private BookService bookService;

    @Test
    void returnsBookStoredInPostgres() {
        bookRepository.saveAndFlush(
                new Book("Testing Spring Boot")
        );

        Book book = bookService.getByTitle("Testing Spring Boot");

        assertThat(book.getTitle()).isEqualTo("Testing Spring Boot");
    }
}</code></pre></div><p>The test context creates the normal service and repository beans, then imports the PostgreSQL container configuration beside them. Saving through <code>BookRepository</code> places the row in PostgreSQL, while the following <code>BookService</code> call passes through the service method, repository query, Hibernate, JDBC driver, and temporary database. The assertion checks the value returned after that call chain completes, so we are testing the service with the same persistence stack configured for the application.</p><p>Placing <code>@Transactional</code> on the test class keeps changes from each test method inside the test transaction, which Spring rolls back afterward. The service already carries <code>@Transactional(readOnly = true)</code>, while the surrounding test transaction lets us prepare data before calling the read method without leaving committed rows behind.</p><p>The missing-record branch can live in the same integration test class:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;c71d9c7f-8e10-4f83-be00-59cc4c879e98&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">@Test
void throwsExceptionWhenBookDoesNotExist() {
    assertThatThrownBy(
            () -&gt; bookService.getByTitle("Missing Book")
    ).isInstanceOf(IllegalArgumentException.class);
}</code></pre></div><p><code>assertThatThrownBy</code> receives the service call as a lambda and checks that it ends with <code>IllegalArgumentException</code>. No row is inserted for the requested title, so <code>findByTitle()</code> returns an empty <code>Optional</code> and <code>BookService</code> reaches its <code>orElseThrow</code> branch. We still pass through the repository and PostgreSQL for the lookup, which lets the test cover the service behavior without replacing persistence with a mock.</p><p>The same service connection model can support other database technologies. MySQL follows a similar relational flow with its Testcontainers module and container class, while MongoDB projects rely on Spring Data MongoDB and <code>MongoDBContainer</code> rather than JPA and JDBC. The container type changes with the database, but Spring Boot can still receive supported connection details through <code>@ServiceConnection</code>.</p><h3>Conclusion</h3><p>Testcontainers gives Spring Boot integration tests a temporary PostgreSQL database that can be started from test configuration and connected through <code>@ServiceConnection</code>. From there, <code>@DataJpaTest</code> can exercise repository behavior through JPA and PostgreSQL, while <code>@SpringBootTest</code> can load the broader application context for service-level testing. The container stays outside production code, Spring Boot supplies the connection details, and each test can run against PostgreSQL without keeping a dedicated test database running.</p><ol><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-boot%2Freference%2Ftesting%2Ftestcontainers.html">Spring Boot Testcontainers Support</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fjava.testcontainers.org%2Fmodules%2Fdatabases%2Fpostgres%2F">Testcontainers PostgreSQL Module</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-boot%2Freference%2Ftesting%2Fspring-boot-applications.html">Spring Boot Data JPA Testing</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fjava.testcontainers.org%2Fmodules%2Fdatabases%2F">Testcontainers Database Modules</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2F%3Futm_source%3Dsubstack%26utm_medium%3Demail%26utm_content%3Dshare%26action%3Dshare"><span>Share Alexander Obregon's Substack</span></a></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21zjcM%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F75341fb5-0e2a-4507-8b75-7decdc140fd8_276x276.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!zjcM!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75341fb5-0e2a-4507-8b75-7decdc140fd8_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!zjcM!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75341fb5-0e2a-4507-8b75-7decdc140fd8_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!zjcM!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75341fb5-0e2a-4507-8b75-7decdc140fd8_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!zjcM!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75341fb5-0e2a-4507-8b75-7decdc140fd8_276x276.png 1456w" sizes="auto, 100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21zjcM%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F75341fb5-0e2a-4507-8b75-7decdc140fd8_276x276.png" width="276" height="276" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/75341fb5-0e2a-4507-8b75-7decdc140fd8_276x276.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:276,&quot;width&quot;:276,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!zjcM!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75341fb5-0e2a-4507-8b75-7decdc140fd8_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!zjcM!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75341fb5-0e2a-4507-8b75-7decdc140fd8_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!zjcM!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75341fb5-0e2a-4507-8b75-7decdc140fd8_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!zjcM!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F75341fb5-0e2a-4507-8b75-7decdc140fd8_276x276.png 1456w" sizes="auto, 100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Ficons8.com%2Ficon%2F90519%2Fspring-boot">Spring Boot</a> icon by <a href="iframe.php?url=https%3A%2F%2Ficons8.com%2F">Icons8</a></figcaption></figure></div>]]></content:encoded></item><item><title><![CDATA[Weekly Recap – September 27th, 2026]]></title><description><![CDATA[Spring Boot and SQL]]></description><link>https://alexanderobregon.substack.com/p/weekly-recap-september-27th-2026</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/weekly-recap-september-27th-2026</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Sun, 27 Sep 2026 17:47:24 GMT</pubDate><content:encoded><![CDATA[<p>Last week I shared two new posts, one on scheduling jobs in Spring Boot and one on calculating medians in SQL. The Spring Boot article walks through fixed rates, fixed delays, and cron expressions with <code>@Scheduled</code>, while the SQL post covers a few ways to get the median from a set of rows. Both are the sort of topics that come up pretty quickly when you start building out backend features and reports.</p><p>If you&#8217;re new or want to know more about what I do, check out my <a href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fabout">About Me page.</a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fsubscribe%3F"><span>Subscribe now</span></a></p><h4><strong>Substack Articles</strong></h4><div class="digest-post-embed" data-attrs="{&quot;nodeId&quot;:&quot;c7e1f775-0918-4b99-997b-c0d2b6844b07&quot;,&quot;caption&quot;:&quot;Routine maintenance can pile up quickly when an application depends on manual steps, command-line scripts, or reminders someone has to remember at the right time. Spring Boot can bring recurring jobs into the application through scheduling support, letting methods run after a fixed delay, at a fixed rate, or at specific times through cron ex&#8230;&quot;,&quot;cta&quot;:null,&quot;showBylines&quot;:true,&quot;showDescription&quot;:true,&quot;showImage&quot;:true,&quot;size&quot;:&quot;sm&quot;,&quot;isEditorNode&quot;:true,&quot;title&quot;:&quot;How to Schedule Jobs in Spring Boot&quot;,&quot;publishedBylines&quot;:[{&quot;id&quot;:185785576,&quot;name&quot;:&quot;Alexander Obregon&quot;,&quot;bio&quot;:&quot;I publish on Medium and use Substack to share recaps, exclusive articles, and a way to support my programming-focused content.&quot;,&quot;photo_url&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8905d669-ef92-4534-9c0c-f486ad372c94_1905x1905.jpeg&quot;,&quot;is_guest&quot;:false,&quot;bestseller_tier&quot;:null}],&quot;post_date&quot;:&quot;2026-09-22T18:33:52.930Z&quot;,&quot;cover_image&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/1a20d152-16e7-488b-92b8-66d2dadbdbaa_480x480.png&quot;,&quot;cover_image_alt&quot;:null,&quot;canonical_url&quot;:&quot;https://alexanderobregon.substack.com/p/how-to-schedule-jobs-in-spring-boot&quot;,&quot;section_name&quot;:&quot;Java and JVM&quot;,&quot;video_upload_id&quot;:null,&quot;id&quot;:216950195,&quot;type&quot;:&quot;newsletter&quot;,&quot;reaction_count&quot;:0,&quot;comment_count&quot;:0,&quot;publication_id&quot;:4719466,&quot;publication_name&quot;:&quot;Alexander Obregon's Substack&quot;,&quot;publication_logo_url&quot;:&quot;&quot;,&quot;belowTheFold&quot;:false,&quot;youtube_url&quot;:null,&quot;show_links&quot;:null,&quot;feed_url&quot;:null}"></div><div class="digest-post-embed" data-attrs="{&quot;nodeId&quot;:&quot;09fc6f12-3a25-4974-b16f-0a7438c64d5a&quot;,&quot;caption&quot;:&quot;Finding the middle value answers a different question than calculating an average. An average adds every value and divides the total by the number of rows, which means unusually high or low values can pull the result away from the center of the data. The median instead orders the values and finds the middle position, making it a good fit for&#8230;&quot;,&quot;cta&quot;:null,&quot;showBylines&quot;:true,&quot;showDescription&quot;:true,&quot;showImage&quot;:true,&quot;size&quot;:&quot;sm&quot;,&quot;isEditorNode&quot;:true,&quot;title&quot;:&quot;Median Calculations With SQL&quot;,&quot;publishedBylines&quot;:[{&quot;id&quot;:185785576,&quot;name&quot;:&quot;Alexander Obregon&quot;,&quot;bio&quot;:&quot;I publish on Medium and use Substack to share recaps, exclusive articles, and a way to support my programming-focused content.&quot;,&quot;photo_url&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8905d669-ef92-4534-9c0c-f486ad372c94_1905x1905.jpeg&quot;,&quot;is_guest&quot;:false,&quot;bestseller_tier&quot;:null}],&quot;post_date&quot;:&quot;2026-09-24T18:16:11.955Z&quot;,&quot;cover_image&quot;:&quot;https://substackcdn.com/image/fetch/$s_!6svP!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F937e08b0-2587-4e59-9e9d-fa6e8bdc80c5_306x306.png&quot;,&quot;cover_image_alt&quot;:null,&quot;canonical_url&quot;:&quot;https://alexanderobregon.substack.com/p/median-calculations-with-sql&quot;,&quot;section_name&quot;:&quot;SQL&quot;,&quot;video_upload_id&quot;:null,&quot;id&quot;:216957381,&quot;type&quot;:&quot;newsletter&quot;,&quot;reaction_count&quot;:0,&quot;comment_count&quot;:0,&quot;publication_id&quot;:4719466,&quot;publication_name&quot;:&quot;Alexander Obregon's Substack&quot;,&quot;publication_logo_url&quot;:&quot;&quot;,&quot;belowTheFold&quot;:false,&quot;youtube_url&quot;:null,&quot;show_links&quot;:null,&quot;feed_url&quot;:null}"></div><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2F%3Futm_source%3Dsubstack%26utm_medium%3Demail%26utm_content%3Dshare%26action%3Dshare"><span>Share Alexander Obregon's Substack</span></a></p>]]></content:encoded></item><item><title><![CDATA[Median Calculations With SQL]]></title><description><![CDATA[Finding the middle value answers a different question than calculating an average.]]></description><link>https://alexanderobregon.substack.com/p/median-calculations-with-sql</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/median-calculations-with-sql</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Thu, 24 Sep 2026 18:16:11 GMT</pubDate><enclosure url="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%216svP%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F937e08b0-2587-4e59-9e9d-fa6e8bdc80c5_306x306.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%216Ihd%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fbea7a848-7d6a-42b3-bfc7-74ceee2074b1_800x373.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!6Ihd!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbea7a848-7d6a-42b3-bfc7-74ceee2074b1_800x373.png 424w, https://substackcdn.com/image/fetch/$s_!6Ihd!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbea7a848-7d6a-42b3-bfc7-74ceee2074b1_800x373.png 848w, https://substackcdn.com/image/fetch/$s_!6Ihd!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbea7a848-7d6a-42b3-bfc7-74ceee2074b1_800x373.png 1272w, https://substackcdn.com/image/fetch/$s_!6Ihd!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbea7a848-7d6a-42b3-bfc7-74ceee2074b1_800x373.png 1456w" sizes="100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%216Ihd%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fbea7a848-7d6a-42b3-bfc7-74ceee2074b1_800x373.png" width="800" height="373" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/bea7a848-7d6a-42b3-bfc7-74ceee2074b1_800x373.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:373,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!6Ihd!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbea7a848-7d6a-42b3-bfc7-74ceee2074b1_800x373.png 424w, https://substackcdn.com/image/fetch/$s_!6Ihd!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbea7a848-7d6a-42b3-bfc7-74ceee2074b1_800x373.png 848w, https://substackcdn.com/image/fetch/$s_!6Ihd!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbea7a848-7d6a-42b3-bfc7-74ceee2074b1_800x373.png 1272w, https://substackcdn.com/image/fetch/$s_!6Ihd!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbea7a848-7d6a-42b3-bfc7-74ceee2074b1_800x373.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Fcommons.wikimedia.org%2Fwiki%2FFile%3ASql_data_base_with_logo.png">Image Source</a></figcaption></figure></div><p>Finding the middle value answers a different question than calculating an average. An average adds every value and divides the total by the number of rows, which means unusually high or low values can pull the result away from the center of the data. The median instead orders the values and finds the middle position, making it a good fit for salaries, prices, response times, order totals, delivery times, and other data where extreme values can affect an average. SQL offers us several ways to calculate that middle point. PostgreSQL, SQL Server, and Oracle has percentile functions that can return the 50th percentile directly, while ranking functions provide another option on database products without the same percentile support. These calculations can cover an entire result set or run within groups such as departments, regions, products, or reporting periods.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fsubscribe%3F"><span>Subscribe now</span></a></p><h3>Median Mechanics</h3><p>Finding a median starts with position rather than addition. We first arrange the values from lowest to highest, then identify the center of that ordered set. The center behaves differently depending on the row count. Odd totals have a single middle value, while even totals have two middle values that are averaged for the usual numeric median. Thinking through the calculation by position gives us a solid foundation for the percentile and ranking methods that come later because both rely on this same ordering logic.</p><h4>Sorting to the Middle</h4><p>Ordering determines which value belongs at the center. If we arrange five order totals from lowest to highest, the third value becomes the median because two values appear below it and two appear above it.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;559d02f5-912a-4895-84cf-85d8b6a80a9c&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">18
25
31
46
120</code></pre></div><p>We can read the ordered values from top to bottom and count inward toward the center. With five rows, position three is the middle, so <code>31</code> becomes the median. No averaging is needed here because an odd row count gives us a single center position.</p><p>That differs from an average, where every value contributes directly to the result. The final value of <code>120</code> raises the average quite a bit, but it does not move the median away from <code>31</code> because its location stays near the upper end of the ordered data. This is why median values can be helpful with prices, salaries, response times, delivery durations, and other numeric data that can contain unusually high or low entries.</p><p>An even row count changes the calculation because no single row occupies the exact center. If we add one more value, the ordered set now contains six rows.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;d1c67c4c-05dc-4fd0-97d2-dd59d27f0f0c&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">18
25
31
46
120
200</code></pre></div><p>We now have two middle positions rather than one. Rows three and four contain <code>31</code> and <code>46</code>, so we average those values to find the numeric median.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;67185ab9-0e83-4aad-b835-0ce980ac9066&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">(31 + 46) / 2 = 38.5</code></pre></div><p>The result becomes <code>38.5</code>, even though that number does not appear anywhere in the stored values. We are measuring the point halfway between the two center positions, so the median can fall between values when the row count is even.</p><p>Duplicate values do not change this rule because every row still occupies a position after sorting. Repeated values can appear before, after, or directly at the center without requiring special treatment.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;5ed9edaf-728a-4720-bdb1-cff298ac8e92&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">10
20
20
20
50</code></pre></div><p>Reading through these five rows places the center at position three, where the value is <code>20</code>. The repeated <code>20</code> values around that position do not alter the calculation because the median depends on ordered position rather than the number of distinct values.</p><p>Null values need different treatment because null represents missing data rather than the number zero. Treating null as zero would introduce a value that was never recorded and could move the center of the ordered set. Median calculations generally exclude null values before counting rows and locating the center.</p><p>Take these values:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;8180bc03-c9cb-437f-897c-aae6a9bd0404&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">15
NULL
25
35
45</code></pre></div><p>After excluding the null, we are left with four numeric values.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;98b01ee6-7265-436d-8511-262570e590f6&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">15
25
35
45</code></pre></div><p>We count four rows, which places the center between positions two and three. Those rows contain <code>25</code> and <code>35</code>, so averaging them gives us a median of <code>30</code>. If the null had been changed to zero, the ordered data would contain five numeric positions and the median would become <code>25</code>, answering a different statistical question because zero was not part of the original data.</p><p>Sorting from highest to lowest would still lead to the same numeric median as long as the positions are handled consistently. Ascending order is usually more natural to read because we can move from the smallest value toward the largest, but the center itself does not change when the ordering direction is reversed.</p><h4>Continuous Versus Discrete Results</h4><p>Percentile calculations give us two related ways to interpret a requested position. Continuous calculation can return a value between stored rows, while discrete calculation returns a value that already exists in the ordered data.</p><p>The difference is easiest to see with an even number of rows. We can return to the six-value set from earlier.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;214e2500-9beb-43a4-8ba8-9d075b78cd33&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">18
25
31
46
120
200</code></pre></div><p>The 50th percentile falls halfway between positions three and four. For a continuous median, those positions contain <code>31</code> and <code>46</code>, so the result becomes <code>38.5</code>.</p><p>We can think of continuous calculation as allowing movement between neighboring numeric values when the requested percentile does not land directly on a row. If the percentile position reaches an exact row, the value from that row is returned without any interpolation.</p><p>The position can be expressed with this formula:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;26f2de96-2a85-4564-8fbd-6e9679d638d0&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">1 + P * (N - 1)</code></pre></div><p><code>P</code> represents the requested percentile as a decimal, while <code>N</code> represents the number of non-null values. For a median, we set <code>P</code> to <code>0.5</code> because the median is the 50th percentile.</p><p>With six rows, we can substitute those values into the formula.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;faa256e1-6bb6-4479-9872-02af1be43b76&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">1 + 0.5 * (6 - 1)
1 + 2.5
3.5</code></pre></div><p>Position <code>3.5</code> falls halfway between rows three and four. We therefore look at the values stored in those two positions and calculate the midpoint between them, which produces the continuous median of <code>38.5</code>.</p><p>Five rows produce a different result because the percentile position falls directly on a row.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;4448c6c9-c7c8-4dd2-a166-5f93d165365e&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">1 + 0.5 * (5 - 1)
1 + 2
3</code></pre></div><p>Position <code>3</code> points directly to the third value. If our ordered values are <code>18</code>, <code>25</code>, <code>31</code>, <code>46</code>, and <code>120</code>, we return <code>31</code> because no interpolation is needed.</p><p>Discrete percentile behavior follows a different rule. Rather than creating a value between neighboring rows, it returns a value already present in the ordered data. The selected value is the first ordered value whose cumulative position reaches the requested percentile.</p><p>For the six-value set containing <code>18</code>, <code>25</code>, <code>31</code>, <code>46</code>, <code>120</code>, and <code>200</code>, a continuous 50th percentile gives us <code>38.5</code> because the center lies between <code>31</code> and <code>46</code>. A discrete percentile does not create <code>38.5</code> between those rows and instead returns an existing value from the ordered set.</p><p>That distinction becomes more relevant when interpolation has a natural numeric meaning. Prices, salaries, durations, and measurements can reasonably produce a midpoint that was never directly recorded because the values exist on a numeric scale. Ordered categories can call for discrete percentile behavior because returning an existing category value can be more appropriate than producing something between two entries.</p><p>Both forms still begin with the same foundation. We order the values, locate the requested percentile position, then determine how to return the result from that location. Continuous calculation can interpolate between neighboring rows, while discrete calculation stays with a value already present in the ordered data.</p><h3>Median Queries</h3><p>Turning the median rules into SQL depends on the database product and the type of result we need. Some databases provide percentile functions that return the 50th percentile directly, while window functions let us number rows and calculate the center ourselves. Reports can also restart the calculation within categories such as departments or regions, which lets the same query return a median for every group rather than a single value across the full table.</p><h4>Percentile Functions</h4><p>PostgreSQL gives us <code>percentile_cont</code>, which calculates a continuous percentile from an ordered set of values. For a median, we request <code>0.5</code> because that represents the 50th percentile.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;9157222b-a8c9-43c4-837b-ed042bcd722d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    percentile_cont(0.5)
        WITHIN GROUP (ORDER BY amount) AS median_amount
FROM sales;</code></pre></div><p>We pass <code>0.5</code> to <code>percentile_cont</code> and place <code>amount</code> inside the <code>ORDER BY</code> expression. <code>WITHIN GROUP</code> tells PostgreSQL which ordering belongs to the percentile calculation. If <code>sales</code> contains an odd number of non-null amounts, the result comes from the center value. With an even count, PostgreSQL interpolates between the two center values when needed. Null values in the ordered input are ignored by PostgreSQL percentile aggregates.</p><p>The <code>ORDER BY</code> inside <code>WITHIN GROUP</code> belongs to the percentile calculation itself and does not sort the final query output. That distinction becomes more relevant when a query returns several rows, but we can read it here as the instruction that determines how values are positioned before the percentile is found.</p><p>SQL Server also provides <code>PERCENTILE_CONT</code>, though its syntax follows a window-function form. We can request the same median like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;6de55c16-6c72-4145-9a73-398bf2afebc8&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT DISTINCT
    PERCENTILE_CONT(0.5)
        WITHIN GROUP (ORDER BY amount)
        OVER () AS median_amount
FROM sales;</code></pre></div><p>The <code>0.5</code> still represents the median, and <code>ORDER BY amount</code> still supplies the ordered numeric values. SQL Server adds <code>OVER()</code> because <code>PERCENTILE_CONT</code> operates as a window function there. Without <code>DISTINCT</code>, the calculated median appears on every qualifying row, which can help when the value needs to stay beside row-level data. When the result only needs the median itself, <code>DISTINCT</code> removes those repeated copies and leaves a single displayed value.</p><p>Oracle supports <code>PERCENTILE_CONT</code> as well and allows it as an aggregate or analytic calculation. Its continuous percentile calculation interpolates between neighboring values when the requested percentile falls between row positions.</p><p>Across these products, the calculation follows the same general idea. We request percentile <code>0.5</code>, provide the numeric expression that needs to be ordered, and let the database return the center according to continuous percentile rules. Exact grammar changes between products, so SQL copied from PostgreSQL to SQL Server or Oracle usually needs syntax changes even when the calculation itself stays the same.</p><h4>Grouped Medians</h4><p>Reports frequently need a median within each category rather than across every row. Department salaries, regional order totals, response times by service, or monthly invoice values all follow this idea. We divide rows into groups, then calculate the median independently inside each group.</p><p>PostgreSQL can pair <code>percentile_cont</code> with <code>GROUP BY</code>. If our <code>sales</code> table contains a <code>region</code> column, we can calculate a regional median like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;dd4982da-08c7-4af8-8928-6fa8bc682d8a&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    region,
    percentile_cont(0.5)
        WITHIN GROUP (ORDER BY amount) AS median_amount
FROM sales
GROUP BY region
ORDER BY region;</code></pre></div><p>We first group the rows by <code>region</code>. PostgreSQL then evaluates the percentile calculation against the <code>amount</code> values belonging to each region rather than against the full table. <code>ORDER BY region</code> at the end controls the displayed row order, while <code>ORDER BY amount</code> inside <code>WITHIN GROUP</code> controls the ordering needed for each median.</p><p>If Eau Claire has amounts of <code>20</code>, <code>30</code>, and <code>80</code>, its median is <code>30</code>. If Madison has <code>10</code>, <code>40</code>, <code>60</code>, and <code>100</code>, its center falls between <code>40</code> and <code>60</code>, producing <code>50</code>. Each region gets its own row count and center position because the aggregation restarts for every group.</p><p>SQL Server handles grouped percentile calculations through <code>PARTITION BY</code> inside the <code>OVER</code> clause rather than an ordinary <code>GROUP BY</code> for <code>PERCENTILE_CONT</code>.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;3438e833-b064-4dc3-a8fb-dc87f4a174f9&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT DISTINCT
    region,
    PERCENTILE_CONT(0.5)
        WITHIN GROUP (ORDER BY amount)
        OVER (PARTITION BY region) AS median_amount
FROM sales
ORDER BY region;</code></pre></div><p><code>PARTITION BY region</code> divides the rows into regional windows. We still order <code>amount</code> within each partition, then SQL Server returns the 50th percentile for that region. <code>DISTINCT</code> removes repeated copies when the report only needs a single median row per region.</p><p>Leaving <code>DISTINCT</code> out gives us a different reporting layout:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;f6c45c78-48c6-406b-b5ec-940d4b93714d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    region,
    amount,
    PERCENTILE_CONT(0.5)
        WITHIN GROUP (ORDER BY amount)
        OVER (PARTITION BY region) AS regional_median
FROM sales
ORDER BY region, amount;</code></pre></div><p>Every sale remains in the result, and the regional median appears beside every row from the same partition. That format can help when we later compare an individual amount with the median for its region.</p><p>Grouped medians depend on the grouping boundary we choose. Changing the partition from <code>region</code> to <code>department</code>, <code>product_category</code>, or a different column changes which rows participate in each median while leaving the percentile rule itself unchanged.</p><h4>Ranking Without Percentile Functions</h4><p>Window functions can calculate a median without <code>PERCENTILE_CONT</code>. We can number the ordered rows, count how many rows belong to the group, retain the middle position or middle pair, then average the retained values.</p><p>Starting with a single population lets us follow the row-position logic directly:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;1363399b-040e-4bc0-9a67-e5794baa9a18&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">WITH ranked_sales AS (
    SELECT
        amount,
        ROW_NUMBER() OVER (ORDER BY amount) AS row_num,
        COUNT(*) OVER () AS row_count
    FROM sales
    WHERE amount IS NOT NULL
)
SELECT
    AVG(amount * 1.0) AS median_amount
FROM ranked_sales
WHERE ABS(row_num - (row_count + 1) / 2.0) &lt; 1;</code></pre></div><p>The common table expression gives every non-null <code>amount</code> an ordered row number and stores the total row count beside it. <code>ROW_NUMBER()</code> starts at <code>1</code>, while <code>COUNT(*) OVER()</code> returns the same population count for every row in that window.</p><p>Next, the filter calculates the mathematical center through <code>(row_count + 1) / 2.0</code>. With five rows, that expression returns <code>3</code>, so only row three remains. With six rows, it returns <code>3.5</code>, which keeps rows three and four because both are less than one position away from the center. <code>AVG</code> then returns the single center value for an odd count or the average of the center pair for an even count.</p><p>Multiplying <code>amount</code> by <code>1.0</code> moves the expression into decimal arithmetic in SQL dialects where integer arithmetic could discard the fractional portion of the result. That keeps a median such as <code>38.5</code> from being reduced to an integer when the surrounding numeric types would otherwise do so.</p><p>Grouped ranking follows the same logic but restarts the numbering and count for each category:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;5444e8f0-fddd-447c-8a88-8c35022ec52c&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">WITH ranked_sales AS (
    SELECT
        region,
        amount,
        ROW_NUMBER() OVER (
            PARTITION BY region
            ORDER BY amount
        ) AS row_num,
        COUNT(*) OVER (
            PARTITION BY region
        ) AS row_count
    FROM sales
    WHERE amount IS NOT NULL
)
SELECT
    region,
    AVG(amount * 1.0) AS median_amount
FROM ranked_sales
WHERE ABS(row_num - (row_count + 1) / 2.0) &lt; 1
GROUP BY region
ORDER BY region;</code></pre></div><p>Both window expressions now contain <code>PARTITION BY region</code>. We number rows independently within each region, then calculate that region&#8217;s row count before filtering for its center. The outer <code>GROUP BY region</code> combines the retained center row or center pair into the final median.</p><p>We can also inspect the intermediate positions before applying the final calculation:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;c7573c23-205f-4595-8d1f-e575c925b77d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    region,
    amount,
    ROW_NUMBER() OVER (
        PARTITION BY region
        ORDER BY amount
    ) AS row_num,
    COUNT(*) OVER (
        PARTITION BY region
    ) AS row_count
FROM sales
WHERE amount IS NOT NULL
ORDER BY region, amount;</code></pre></div><p>If a region contains four amounts, the result gives them row numbers <code>1</code> through <code>4</code> and repeats a <code>row_count</code> of <code>4</code> beside every row. We can then see why positions <code>2</code> and <code>3</code> form the center pair. With five amounts, numbering runs from <code>1</code> through <code>5</code>, leaving position <code>3</code> at the center.</p><p>Duplicate numeric values do not break this median calculation. <code>ROW_NUMBER()</code> gives tied rows different position numbers, but equal numeric values remain equal regardless of which tied row receives the earlier number. Their internal order therefore does not change the numeric median when the ordering expression is the value being measured.</p><p>MySQL 8.4 provides <code>ROW_NUMBER()</code> and the window clauses needed for this ranking technique. Its window-function set includes <code>ROW_NUMBER</code>, <code>RANK</code>, <code>DENSE_RANK</code>, <code>CUME_DIST</code>, and <code>PERCENT_RANK</code>, which gives us the pieces needed to build the row-position method.</p><h4>Database Differences</h4><p>Exact median syntax varies enough between database products that queries usually need product-specific forms. PostgreSQL 18 provides <code>percentile_cont</code> and <code>percentile_disc</code> as ordered-set aggregates. The continuous form can interpolate between adjacent values, while the discrete form returns an input value from the ordered set. Null values in their aggregated input are ignored.</p><p>SQL Server has <code>PERCENTILE_CONT</code> with <code>WITHIN GROUP</code> and an <code>OVER</code> clause. Current SQL Server releases also provide <code>APPROX_PERCENTILE_CONT</code> when an approximate percentile is acceptable. That function is available in SQL Server 2022 and later and returns an approximate percentile rather than the exact result produced by <code>PERCENTILE_CONT</code>.</p><p>Its syntax differs from the windowed exact form:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;c3bdbf8d-5672-4d6a-bf24-a19957a96973&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT
    region,
    APPROX_PERCENTILE_CONT(0.5)
        WITHIN GROUP (ORDER BY amount) AS approximate_median
FROM sales
GROUP BY region;</code></pre></div><p>We group rows by <code>region</code>, then request the approximate 50th percentile from each regional set. The function can reduce memory demand on large data sets, though the returned percentile remains approximate and should not replace an exact median when the report requires the exact mathematical result. Null values are ignored by <code>APPROX_PERCENTILE_CONT</code>.</p><p>Oracle provides <code>PERCENTILE_CONT</code> and <code>PERCENTILE_DISC</code> as aggregate and analytic functions. Its continuous form applies linear interpolation between ordered rows when the percentile position falls between them, while the discrete form returns a value already present in the ordered set.</p><p>MySQL 8.4 includes window functions like <code>ROW_NUMBER</code>, <code>RANK</code>, <code>DENSE_RANK</code>, <code>NTILE</code>, <code>CUME_DIST</code>, and <code>PERCENT_RANK</code>, but it does not provide <code>PERCENTILE_CONT</code> in its window-function set. The row-number method therefore gives us an exact median calculation on MySQL 8.4 while following the same center-row logic covered earlier.</p><p>The mathematical calculation stays familiar across these database products, but the SQL that expresses it changes. PostgreSQL can aggregate directly with <code>percentile_cont</code>, SQL Server places exact <code>PERCENTILE_CONT</code> inside a window, Oracle supports aggregate and analytic forms, and MySQL can derive the result through row numbering. Keeping those dialect differences in view helps prevent valid SQL from being copied into a database whose grammar expects a different form.</p><h3>Conclusion</h3><p>Median calculations come down to ordering values, locating the center, and handling odd or even row counts correctly. SQL can reach that result through percentile functions or row ranking, while grouped calculations repeat the same logic within each category. The exact syntax changes across database products, but the core mechanics stay the same from the first ordered value through the final median.</p><ol><li><p><em><a href="iframe.php?url=https%3A%2F%2Fwww.postgresql.org%2Fdocs%2Fcurrent%2Ffunctions-aggregate.html">PostgreSQL Aggregate Functions</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Flearn.microsoft.com%2Fen-us%2Fsql%2Ft-sql%2Ffunctions%2Fpercentile-cont-transact-sql%3Fview%3Dsql-server-ver17">SQL Server PERCENTILE_CONT</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Flearn.microsoft.com%2Fen-us%2Fsql%2Ft-sql%2Ffunctions%2Fapprox-percentile-cont-transact-sql%3Fview%3Dsql-server-ver17">SQL Server APPROX_PERCENTILE_CONT</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.oracle.com%2Fen%2Fdatabase%2Foracle%2Foracle-database%2F19%2Fsqlrf%2FPERCENTILE_CONT.html">Oracle PERCENTILE_CONT</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdev.mysql.com%2Fdoc%2Frefman%2F8.4%2Fen%2Fwindow-function-descriptions.html">MySQL Window Function Reference</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2F%3Futm_source%3Dsubstack%26utm_medium%3Demail%26utm_content%3Dshare%26action%3Dshare"><span>Share Alexander Obregon's Substack</span></a></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%216svP%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F937e08b0-2587-4e59-9e9d-fa6e8bdc80c5_306x306.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!6svP!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F937e08b0-2587-4e59-9e9d-fa6e8bdc80c5_306x306.png 424w, https://substackcdn.com/image/fetch/$s_!6svP!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F937e08b0-2587-4e59-9e9d-fa6e8bdc80c5_306x306.png 848w, https://substackcdn.com/image/fetch/$s_!6svP!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F937e08b0-2587-4e59-9e9d-fa6e8bdc80c5_306x306.png 1272w, https://substackcdn.com/image/fetch/$s_!6svP!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F937e08b0-2587-4e59-9e9d-fa6e8bdc80c5_306x306.png 1456w" sizes="auto, 100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%216svP%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F937e08b0-2587-4e59-9e9d-fa6e8bdc80c5_306x306.png" width="306" height="306" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/937e08b0-2587-4e59-9e9d-fa6e8bdc80c5_306x306.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:306,&quot;width&quot;:306,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!6svP!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F937e08b0-2587-4e59-9e9d-fa6e8bdc80c5_306x306.png 424w, https://substackcdn.com/image/fetch/$s_!6svP!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F937e08b0-2587-4e59-9e9d-fa6e8bdc80c5_306x306.png 848w, https://substackcdn.com/image/fetch/$s_!6svP!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F937e08b0-2587-4e59-9e9d-fa6e8bdc80c5_306x306.png 1272w, https://substackcdn.com/image/fetch/$s_!6svP!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F937e08b0-2587-4e59-9e9d-fa6e8bdc80c5_306x306.png 1456w" sizes="auto, 100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Ffreesvg.org%2Fmono-sql">Image Source</a></figcaption></figure></div>]]></content:encoded></item><item><title><![CDATA[How to Schedule Jobs in Spring Boot]]></title><description><![CDATA[Routine maintenance can pile up quickly when an application depends on manual steps, command-line scripts, or reminders someone has to remember at the right time.]]></description><link>https://alexanderobregon.substack.com/p/how-to-schedule-jobs-in-spring-boot</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/how-to-schedule-jobs-in-spring-boot</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Tue, 22 Sep 2026 18:33:52 GMT</pubDate><enclosure url="iframe.php?url=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1a20d152-16e7-488b-92b8-66d2dadbdbaa_480x480.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%2130xx%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F974b09fd-216a-4f74-b3c6-81a4f888c940_800x444.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!30xx!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F974b09fd-216a-4f74-b3c6-81a4f888c940_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!30xx!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F974b09fd-216a-4f74-b3c6-81a4f888c940_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!30xx!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F974b09fd-216a-4f74-b3c6-81a4f888c940_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!30xx!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F974b09fd-216a-4f74-b3c6-81a4f888c940_800x444.jpeg 1456w" sizes="100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%2130xx%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F974b09fd-216a-4f74-b3c6-81a4f888c940_800x444.jpeg" width="800" height="444" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/974b09fd-216a-4f74-b3c6-81a4f888c940_800x444.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:444,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!30xx!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F974b09fd-216a-4f74-b3c6-81a4f888c940_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!30xx!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F974b09fd-216a-4f74-b3c6-81a4f888c940_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!30xx!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F974b09fd-216a-4f74-b3c6-81a4f888c940_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!30xx!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F974b09fd-216a-4f74-b3c6-81a4f888c940_800x444.jpeg 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Fspring.io%2Fprojects%2Fspring-boot">Image Source</a></figcaption></figure></div><p>Routine maintenance can pile up quickly when an application depends on manual steps, command-line scripts, or reminders someone has to remember at the right time. Spring Boot can bring recurring jobs into the application through scheduling support, letting methods run after a fixed delay, at a fixed rate, or at specific times through cron expressions. Time zones can be assigned directly to cron schedules, which keeps clock-based jobs tied to the intended region rather than the server&#8217;s local time. That makes scheduling a good option for removing expired records, refreshing stored data, generating reports, or checking for unfinished processing, while giving you control over how each run is timed and what happens if an earlier run is still active.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fsubscribe%3F"><span>Subscribe now</span></a></p><h3>Starting Scheduled Jobs</h3><p>Spring&#8217;s annotation-based scheduler lets recurring application jobs live beside the services they call instead of depending on someone to launch a script at the right time. We first want to activate scheduling support, after which Spring can register methods marked with <code>@Scheduled</code> as the application context starts. From there, each job can follow a timing rule that matches what it needs to do, while the method itself stays focused on the operation being performed.</p><h4>Turn On Scheduling</h4><p>Before Spring can register <code>@Scheduled</code> methods, scheduling support has to be active in the application context. <code>@EnableScheduling</code> handles that, and the main Spring Boot application class is a common place for the annotation because it keeps the scheduling configuration near the application entry point.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;2d672d76-3543-4eef-af79-13c741dd0fb9&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.scheduling;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
public class SchedulingApplication {

    public static void main(String[] args) {
        SpringApplication.run(SchedulingApplication.class, args);
    }
}</code></pre></div><p>We add <code>@EnableScheduling</code> beside <code>@SpringBootApplication</code>, so Spring checks its managed beans for scheduled methods during application startup. <code>SpringApplication.run()</code> creates the application context as usual, while <code>@EnableScheduling</code> adds the scheduling support needed for Spring to register methods carrying <code>@Scheduled</code>.</p><p>That managed-bean detail is important because placing <code>@Scheduled</code> on an object created manually with <code>new</code> does not place that object under Spring&#8217;s scheduling management. Classes containing scheduled methods are commonly registered through annotations such as <code>@Component</code> or <code>@Service</code>, which lets Spring create the instance and inspect it during startup.</p><p>The scheduler belongs to the running application process, so scheduled methods stop when the application stops. Spring does not store missed executions in a durable queue for later recovery, which means a basic scheduled method should be treated as part of the application&#8217;s active runtime rather than as a persistent job record. If the process is offline when a scheduled time passes, that missed execution is not automatically replayed after startup.</p><h4>Create the First Job</h4><p>Scheduled methods are commonly kept small so they can call a service that contains the application logic. This keeps timing concerns in the scheduling class while database access, file processing, HTTP calls, and other application behavior stay in the service that already owns them.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;565e6cec-e3f9-4c60-9f4a-c4a9dae51d85&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.scheduling;

import java.util.concurrent.TimeUnit;

import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class MaintenanceJobs {

    @Scheduled(fixedDelay = 15, timeUnit = TimeUnit.MINUTES)
    public void removeExpiredRecords() {
        System.out.println("Removing expired records");
    }
}</code></pre></div><p>Spring registers <code>MaintenanceJobs</code> because <code>@Component</code> makes the class a managed bean. The <code>@Scheduled</code> annotation tells Spring when <code>removeExpiredRecords()</code> should be called, while <code>timeUnit = TimeUnit.MINUTES</code> lets us express the interval in minutes instead of converting fifteen minutes into milliseconds. Without <code>timeUnit</code>, Spring treats fixed delay, fixed rate, and initial delay values as milliseconds.</p><p>The method itself takes no arguments, which lets Spring call it without having to supply input values. Returning <code>void</code> also matches the common synchronous form because the scheduler is interested in invoking the method at the configured time rather than receiving a value back from it.</p><p>As the operation grows beyond a small demonstration, we can move the application logic into a service and leave the scheduling method responsible for triggering that service.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;4d894db4-c774-4bee-a548-aeb0fe742344&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.scheduling;

import java.util.concurrent.TimeUnit;

import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class MaintenanceJobs {

    private final ExpiredRecordService expiredRecordService;

    public MaintenanceJobs(ExpiredRecordService expiredRecordService) {
        this.expiredRecordService = expiredRecordService;
    }

    @Scheduled(fixedDelay = 15, timeUnit = TimeUnit.MINUTES)
    public void removeExpiredRecords() {
        expiredRecordService.removeExpiredRecords();
    }
}</code></pre></div><p>Spring supplies <code>ExpiredRecordService</code> through the constructor when it creates <code>MaintenanceJobs</code>, and the scheduled method then calls <code>removeExpiredRecords()</code> at the configured interval. That keeps the scheduling class centered on timing while the service handles the actual record removal. Method names are worth keeping specific because scheduled jobs usually run without someone directly triggering them. Names such as <code>removeExpiredRecords</code>, <code>refreshCatalogCache</code>, or <code>recalculateDailyTotals</code> make logs and stack traces more informative than a generic name such as <code>run</code>.</p><p>Spring also supports reactive scheduled methods, but regular synchronous jobs can stay with the no-argument form shown above. For the maintenance jobs covered here, that keeps the execution flow easy to follow while we focus on how Spring determines the next run time.</p><h4>Run With a Fixed Delay</h4><p>With <code>fixedDelay</code>, Spring calculates the next execution from the completion of the previous execution. The waiting period begins after the method finishes, so the amount of processing time naturally affects when the following run begins.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;272cd5dc-6905-4fec-9c9b-ea94c2a35b23&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">@Scheduled(
    initialDelay = 1,
    fixedDelay = 10,
    timeUnit = TimeUnit.MINUTES
)
public void removeExpiredSessions() {
    sessionService.removeExpiredSessions();
}</code></pre></div><p>We give the first execution a one-minute startup delay through <code>initialDelay</code>, then tell Spring to wait ten minutes after every completed run through <code>fixedDelay</code>. If <code>removeExpiredSessions()</code> takes three minutes, Spring waits until those three minutes are finished before starting the ten-minute delay. The next execution therefore begins about thirteen minutes after the previous start.</p><p>Longer processing follows the same rule, if session removal takes twenty minutes, Spring waits for those twenty minutes to finish and then waits the full ten-minute delay before calling the method again. Execution duration becomes part of the overall spacing between starts because the delay clock does not begin until the previous call has returned.</p><p>This timing model works well for recurring operations where some breathing room after completion is desirable. Polling a data source, deleting expired sessions, checking an import directory, or refreshing data from a remote service can all benefit from a full pause between runs because the next execution does not try to preserve an earlier start schedule.</p><p><code>initialDelay</code> affects only the first execution after scheduling begins. Later calls follow the configured <code>fixedDelay</code>, so the startup pause does not get added again after every run.</p><p>Fixed delay can also make timing easier to reason through when processing duration changes. We always have the same relationship between executions, which is completion first, followed by the configured delay, followed by the next start.</p><h4>Run at a Fixed Rate</h4><p>With <code>fixedRate</code>, Spring bases the schedule on planned start times rather than waiting for the previous execution to finish and then beginning a new delay. If the rate is five minutes, the scheduler targets five-minute intervals from the established schedule:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;da56315d-771b-482d-bb86-e185e38a0c4b&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">@Scheduled(
    initialDelay = 30,
    fixedRate = 5,
    timeUnit = TimeUnit.MINUTES
)
public void refreshInventoryTotals() {
    inventoryService.refreshTotals();
}</code></pre></div><p>We delay the first execution by thirty minutes, then give later executions a five-minute fixed rate. If <code>refreshInventoryTotals()</code> finishes well before the next planned start, the remaining time passes before Spring calls it again. If processing takes longer than five minutes, a later execution can begin late because the scheduled start time has already passed.</p><p>With Spring Boot&#8217;s regular <code>ThreadPoolTaskScheduler</code>, successive executions of the same fixed-rate job do not run concurrently. If one execution is still running when the next scheduled start arrives, that later call waits rather than running beside the earlier call. With virtual threads enabled, Spring Boot uses <code>SimpleAsyncTaskScheduler</code>, which can start fixed-rate executions on separate threads.</p><p>The difference from fixed delay becomes easier to see when the method takes a noticeable amount of time. If a fixed-rate job begins at 2 PM with a five-minute period, its planned schedule continues around 2:05, 2:10, 2:15, and later intervals. If an execution lasts two minutes, the remaining time before the next planned start is about three minutes.</p><p>With a five-minute fixed delay, that same two-minute execution would finish first and then begin a full five-minute wait. The next start would come about seven minutes after the earlier start instead of following the original five-minute cadence. Fixed rate is a good match for recurring activity where planned start cadence is more important than leaving a full pause after completion. Refreshing counters, sampling internal values, or recalculating stored data at regular intervals can follow that timing model when the processing normally finishes comfortably inside its configured period.</p><p>Fixed delay and fixed rate therefore answer different timing needs. Fixed delay measures from completion, while fixed rate follows planned start intervals, and recognizing that difference early keeps scheduled behavior from becoming surprising later.</p><h3>Controlling Job Timing</h3><p>Clock-based scheduling gives recurring jobs a precise calendar instead of relying only on elapsed intervals. We can tell Spring to call a method at a particular minute, hour, weekday, or combination of calendar fields, then assign a time zone so the schedule follows the intended region. Timing also deserves closer attention as an application gains more scheduled jobs because scheduler threads, repeated declarations, asynchronous execution, and multiple application instances can affect when calls begin and how they interact.</p><h4>Schedule With Cron</h4><p>Cron expressions are a good choice when a job needs to follow the clock rather than wait for a repeating interval. Spring cron expressions contain six fields ordered as second, minute, hour, day of month, month, and day of week. Remembering the seconds field helps when moving between Spring and cron formats that contain five fields because leaving it out changes the meaning of the expression.</p><p>For a nightly archive job that should begin at 2:30 AM, we can place the full schedule directly on the method:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;cf3d38d3-0dc0-487d-9040-27790a57d639&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">@Scheduled(cron = "0 30 2 * * *")
public void archiveOldEvents() {
    eventService.archiveOldEvents();
}</code></pre></div><p>Reading from left to right, the first <code>0</code> selects second zero, <code>30</code> selects minute thirty, and <code>2</code> selects the 2 AM hour. The remaining wildcard fields allow every day of the month, every month, and every day of the week, so Spring schedules <code>archiveOldEvents()</code> for 2:30 AM each day.</p><p>Weekday schedules can narrow the final field to the days we want. If a report should be generated at 6 AM from Monday through Friday, we can express the schedule like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;95502536-8a4c-4d4f-82cd-7ef15e8942e4&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">@Scheduled(cron = "0 0 6 * * MON-FRI")
public void createMorningReport() {
    reportService.createMorningReport();
}</code></pre></div><p>We still have six fields, but the final field now limits execution to Monday through Friday. The first three values select second zero, minute zero, and hour six, while the two middle calendar fields allow every day of the month and every month. Spring therefore calls <code>createMorningReport()</code> at 6 AM on weekdays.</p><p>Clock-based jobs can also carry a defined time zone when a local business time needs to stay consistent across deployments. The <code>zone</code> attribute on <code>@Scheduled</code> tells Spring which region should be applied when it evaluates the cron expression:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;f72a0c32-7365-497b-9781-1aa2cd5e7453&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">@Scheduled(
    cron = "0 30 2 * * *",
    zone = "America/Chicago"
)
public void archiveOldEvents() {
    eventService.archiveOldEvents();
}</code></pre></div><p>We now tie the 2:30 AM execution to <code>America/Chicago</code>, so Spring follows that region rather than relying on the default time zone of the host process. Region IDs such as <code>America/Chicago</code> also carry daylight saving time rules, which makes them appropriate for schedules tied to local civil time rather than a fixed UTC offset.</p><p>Keeping the cron expression outside Java can help when environments need different run times. Spring resolves property placeholders inside <code>@Scheduled</code>, so we can move both the expression and zone into application configuration:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;09606686-f19a-4035-8bf3-87139d6b9982&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">@Scheduled(
    cron = "${jobs.archive.cron}",
    zone = "${jobs.archive.zone}"
)
public void archiveOldEvents() {
    eventService.archiveOldEvents();
}</code></pre></div><p>The matching <code>application.properties</code> entries can hold the actual values:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;5e83d32c-f8b6-4a39-8402-7e5fdc17c4cd&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">jobs.archive.cron=0 30 2 * * *
jobs.archive.zone=America/Chicago</code></pre></div><p>We leave the Java method unchanged while configuration supplies its clock time and region. Development, staging, and production can then carry different values without editing the scheduling method for every environment.</p><p>Spring also accepts cron macros such as <code>@hourly</code>, <code>@daily</code>, <code>@weekly</code>, <code>@monthly</code>, and <code>@yearly</code>. These macros replace common six-field expressions when their built-in timing matches what we need:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;c8b943e0-cfc8-46a8-8d5a-eab81def392d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">@Scheduled(cron = "@hourly")
public void refreshSummaryData() {
    summaryService.refresh();
}</code></pre></div><p>We tell Spring to call <code>refreshSummaryData()</code> at the start of every hour, which corresponds to <code>0 0 * * * *</code>. More specific schedules still benefit from an explicit six-field expression, such as a weekday run at 6:15 AM or a nightly operation tied to a named region.</p><p>Cron timing also affects what happens when an execution lasts long enough to pass a later cron time. With the regular thread-pool scheduler, Spring&#8217;s cron trigger calculates its next execution from completion of the preceding run, so a cron time that passes while that run is still active can be skipped rather than replayed immediately afterward. When virtual threads are active, Spring Boot uses <code>SimpleAsyncTaskScheduler</code>, which can start cron executions on new threads, so overlap behavior depends on the scheduler being used.</p><h4>Prevent Unexpected Overlap</h4><p>Concurrency becomes more relevant when several scheduled methods share the same application process or when more than one trigger can reach the same operation. With virtual threads turned off, Spring Boot starts with a scheduling pool size of <code>1</code>, so a long-running scheduled method can occupy the scheduler thread while a different scheduled method is ready to begin.</p><p>The scheduler pool can be increased through configuration:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;0867c21c-729f-4ede-9b88-000cb379f97c&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">spring.task.scheduling.pool.size=4</code></pre></div><p>We now give Spring four scheduler threads instead of the default single thread, allowing different scheduled methods to execute at the same time when capacity remains. The pool-size property does not control scheduler concurrency when Spring Boot virtual threads are active, so that configuration applies to the regular platform-thread scheduler.</p><p>More scheduler threads can reduce delays between unrelated jobs, but they also allow those jobs to reach the database, filesystem, remote services, or shared application state at the same time. Increasing the pool therefore changes more than execution capacity because operations that previously ran one after the other can now run concurrently. Repeated <code>@Scheduled</code> declarations deserve closer attention because Spring processes every declaration independently. The annotation is repeatable, so the same method can carry more than one schedule, and those schedules can reach the method independently if their timing becomes close enough.</p><p>We can protect a locally scheduled import with a <code>ReentrantLock</code> when several local triggers or other entry points can reach the same operation:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;1aa9c228-dcb3-427f-ae5a-3c98399cf2a7&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.scheduling;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class ImportJobs {

    private final Lock importLock = new ReentrantLock();
    private final UpdateService updateService;

    public ImportJobs(UpdateService updateService) {
        this.updateService = updateService;
    }

    @Scheduled(cron = "0 0 8 * * MON-FRI")
    @Scheduled(cron = "0 0 12 * * MON-FRI")
    public void importUpdates() {
        if (!importLock.tryLock()) {
            return;
        }

        try {
            updateService.importUpdates();
        } finally {
            importLock.unlock();
        }
    }
}</code></pre></div><p>We give <code>importUpdates()</code> two independent weekday schedules, with calls planned for 8 AM and noon. Before starting the import, <code>tryLock()</code> checks the local lock. If an earlier invocation still owns it, the later call returns without starting a second import. The <code>finally</code> block releases the lock after <code>updateService.importUpdates()</code> finishes, including cases where that service call throws an exception.</p><p>That protection reaches only the current JVM. If the same Spring Boot application runs in several instances, every instance has its own scheduler and its own <code>ReentrantLock</code>, so each instance can still start the import independently. Jobs that must execute a single time across several application instances need coordination shared by those instances, such as a database-backed lock, distributed locking mechanism, or scheduler built for clustered execution.</p><p>Asynchronous execution changes the timing boundary as well. If a scheduled method sends processing to an asynchronous executor and returns before that processing finishes, the scheduler treats the method invocation as completed while the asynchronous operation remains active. Later triggers can then submit more processing before the earlier asynchronous call has finished, so operations that must remain single-flight need protection around the processing itself rather than only around the scheduling callback.</p><h3>Conclusion</h3><p>Spring Boot scheduling comes down to choosing how each recurring job should run, then matching that timing to <code>@Scheduled</code>. Fixed delay waits from the end of the previous run, fixed rate follows planned start intervals, and cron expressions handle clock-based schedules with optional time zones, while scheduler configuration and locking help keep overlapping executions from creating duplicate processing.</p><ol><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-framework%2Freference%2Fintegration%2Fscheduling.html">Spring Framework Task Execution and Scheduling</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-framework%2Fdocs%2Fcurrent%2Fjavadoc-api%2Forg%2Fspringframework%2Fscheduling%2Fannotation%2FScheduled.html">Spring </a></em><code>@Scheduled</code><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-framework%2Fdocs%2Fcurrent%2Fjavadoc-api%2Forg%2Fspringframework%2Fscheduling%2Fannotation%2FScheduled.html"> Documentation</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-framework%2Fdocs%2Fcurrent%2Fjavadoc-api%2Forg%2Fspringframework%2Fscheduling%2Fsupport%2FCronExpression.html">Spring </a></em><code>CronExpression</code><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-framework%2Fdocs%2Fcurrent%2Fjavadoc-api%2Forg%2Fspringframework%2Fscheduling%2Fsupport%2FCronExpression.html"> Documentation</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-framework%2Fdocs%2Fcurrent%2Fjavadoc-api%2Forg%2Fspringframework%2Fscheduling%2Fsupport%2FCronTrigger.html">Spring </a></em><code>CronTrigger</code><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-framework%2Fdocs%2Fcurrent%2Fjavadoc-api%2Forg%2Fspringframework%2Fscheduling%2Fsupport%2FCronTrigger.html"> Documentation</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.spring.io%2Fspring-boot%2Fappendix%2Fapplication-properties%2Findex.html">Spring Boot Application Properties</a></em></p></li><li><p><em><a href="iframe.php?url=https%3A%2F%2Fdocs.oracle.com%2Fen%2Fjava%2Fjavase%2F25%2Fdocs%2Fapi%2Fjava.base%2Fjava%2Futil%2Fconcurrent%2FScheduledThreadPoolExecutor.html">Java </a></em><code>ScheduledThreadPoolExecutor</code><em><a href="iframe.php?url=https%3A%2F%2Fdocs.oracle.com%2Fen%2Fjava%2Fjavase%2F25%2Fdocs%2Fapi%2Fjava.base%2Fjava%2Futil%2Fconcurrent%2FScheduledThreadPoolExecutor.html"> Documentation</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2F%3Futm_source%3Dsubstack%26utm_medium%3Demail%26utm_content%3Dshare%26action%3Dshare"><span>Share Alexander Obregon's Substack</span></a></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%214Obd%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F762e3b8b-750c-42e0-a944-4a3fd46e76fe_276x276.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!4Obd!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F762e3b8b-750c-42e0-a944-4a3fd46e76fe_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!4Obd!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F762e3b8b-750c-42e0-a944-4a3fd46e76fe_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!4Obd!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F762e3b8b-750c-42e0-a944-4a3fd46e76fe_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!4Obd!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F762e3b8b-750c-42e0-a944-4a3fd46e76fe_276x276.png 1456w" sizes="auto, 100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%214Obd%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F762e3b8b-750c-42e0-a944-4a3fd46e76fe_276x276.png" width="276" height="276" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/762e3b8b-750c-42e0-a944-4a3fd46e76fe_276x276.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:276,&quot;width&quot;:276,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!4Obd!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F762e3b8b-750c-42e0-a944-4a3fd46e76fe_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!4Obd!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F762e3b8b-750c-42e0-a944-4a3fd46e76fe_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!4Obd!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F762e3b8b-750c-42e0-a944-4a3fd46e76fe_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!4Obd!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F762e3b8b-750c-42e0-a944-4a3fd46e76fe_276x276.png 1456w" sizes="auto, 100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="iframe.php?url=https%3A%2F%2Ficons8.com%2Ficon%2F90519%2Fspring-boot">Spring Boot</a> icon by <a href="iframe.php?url=https%3A%2F%2Ficons8.com%2F">Icons8</a></figcaption></figure></div>]]></content:encoded></item><item><title><![CDATA[Weekly Recap – September 20th, 2026]]></title><description><![CDATA[Spring Boot/Spring Framework]]></description><link>https://alexanderobregon.substack.com/p/weekly-recap-september-20th-2026</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/weekly-recap-september-20th-2026</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Sun, 20 Sep 2026 12:29:27 GMT</pubDate><content:encoded><![CDATA[<p>Spring Boot ended up taking over most of what I posted over the last week after I got on a bit of a personal roll revisiting a few areas I wanted to refresh. That turned into posts on JPA batch inserts for better database performance, production logging, and running asynchronous methods with <code>@Async</code>. Going back through those topics was a good refresher for me, and it gave me a chance to turn what I was relearning into a few articles along the way.</p><p><span>If you&#8217;re new or want to know more about what I do, check out my </span><a href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fabout">About Me page.</a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2Fsubscribe%3F"><span>Subscribe now</span></a></p><h4><strong>Substack Articles</strong></h4><div class="digest-post-embed" data-attrs="{&quot;nodeId&quot;:&quot;d53aca51-ff9a-49a4-b20a-f0411804290e&quot;,&quot;caption&quot;:&quot;Saving a few rows through JPA rarely puts much pressure on a database, but importing hundreds or thousands of entities changes how much database activity is involved. Every insert can involve JDBC calls, network round trips, database parsing, identifier generation, and memory inside the persistence context. Hibernate can group compatible ins&#8230;&quot;,&quot;cta&quot;:null,&quot;showBylines&quot;:true,&quot;showDescription&quot;:true,&quot;showImage&quot;:true,&quot;size&quot;:&quot;sm&quot;,&quot;isEditorNode&quot;:true,&quot;title&quot;:&quot;Spring Boot JPA Batch Inserts for Better Database Performance&quot;,&quot;publishedBylines&quot;:[{&quot;id&quot;:185785576,&quot;name&quot;:&quot;Alexander Obregon&quot;,&quot;bio&quot;:&quot;I publish on Medium and use Substack to share recaps, exclusive articles, and a way to support my programming-focused content.&quot;,&quot;photo_url&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8905d669-ef92-4534-9c0c-f486ad372c94_1905x1905.jpeg&quot;,&quot;is_guest&quot;:false,&quot;bestseller_tier&quot;:null}],&quot;post_date&quot;:&quot;2026-09-15T01:19:00.046Z&quot;,&quot;cover_image&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/6b442ead-67d0-4d4c-85af-7fd2ee88a4e6_480x480.png&quot;,&quot;cover_image_alt&quot;:null,&quot;cover_image_crop&quot;:null,&quot;canonical_url&quot;:&quot;https://alexanderobregon.substack.com/p/spring-boot-jpa-batch-inserts-for&quot;,&quot;section_name&quot;:&quot;Java and JVM&quot;,&quot;video_upload_id&quot;:null,&quot;id&quot;:215749891,&quot;type&quot;:&quot;newsletter&quot;,&quot;reaction_count&quot;:0,&quot;comment_count&quot;:0,&quot;publication_id&quot;:4719466,&quot;publication_name&quot;:&quot;Alexander Obregon's Substack&quot;,&quot;publication_logo_url&quot;:&quot;&quot;,&quot;belowTheFold&quot;:false,&quot;youtube_url&quot;:null,&quot;show_links&quot;:null,&quot;feed_url&quot;:null}"></div><div class="digest-post-embed" data-attrs="{&quot;nodeId&quot;:&quot;4b685a5f-07ad-48b3-9ad0-ae79d6597b77&quot;,&quot;caption&quot;:&quot;Production logging becomes far more valuable when each entry carries enough context to answer practical questions without making someone dig through long blocks of text. Spring Boot applications can already record controller activity, service events, database failures, and exceptions, but finding the event tied to a specific request gets har&#8230;&quot;,&quot;cta&quot;:null,&quot;showBylines&quot;:true,&quot;showDescription&quot;:true,&quot;showImage&quot;:true,&quot;size&quot;:&quot;sm&quot;,&quot;isEditorNode&quot;:true,&quot;title&quot;:&quot;How to Improve Spring Boot Logging for Production&quot;,&quot;publishedBylines&quot;:[{&quot;id&quot;:185785576,&quot;name&quot;:&quot;Alexander Obregon&quot;,&quot;bio&quot;:&quot;I publish on Medium and use Substack to share recaps, exclusive articles, and a way to support my programming-focused content.&quot;,&quot;photo_url&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8905d669-ef92-4534-9c0c-f486ad372c94_1905x1905.jpeg&quot;,&quot;is_guest&quot;:false,&quot;bestseller_tier&quot;:null}],&quot;post_date&quot;:&quot;2026-09-16T17:10:31.442Z&quot;,&quot;cover_image&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/835fb2eb-9853-49a6-b48c-8b52074caa83_480x480.png&quot;,&quot;cover_image_alt&quot;:null,&quot;cover_image_crop&quot;:null,&quot;canonical_url&quot;:&quot;https://alexanderobregon.substack.com/p/how-to-improve-spring-boot-logging&quot;,&quot;section_name&quot;:&quot;Java and JVM&quot;,&quot;video_upload_id&quot;:null,&quot;id&quot;:215909833,&quot;type&quot;:&quot;newsletter&quot;,&quot;reaction_count&quot;:0,&quot;comment_count&quot;:0,&quot;publication_id&quot;:4719466,&quot;publication_name&quot;:&quot;Alexander Obregon's Substack&quot;,&quot;publication_logo_url&quot;:&quot;&quot;,&quot;belowTheFold&quot;:false,&quot;youtube_url&quot;:null,&quot;show_links&quot;:null,&quot;feed_url&quot;:null}"></div><div class="digest-post-embed" data-attrs="{&quot;nodeId&quot;:&quot;89f2c2b9-967a-4af7-8efa-f0a259b74be7&quot;,&quot;caption&quot;:&quot;Slow operations do not always need to hold the HTTP request thread until every step finishes. Sending email, generating a report, recording an audit event through a remote service, or calling a slower external API can run on another executor when the caller does not need the result before continuing. Spring handles this with&quot;,&quot;cta&quot;:null,&quot;showBylines&quot;:true,&quot;showDescription&quot;:true,&quot;showImage&quot;:true,&quot;size&quot;:&quot;sm&quot;,&quot;isEditorNode&quot;:true,&quot;title&quot;:&quot;How to Run Async Methods in Spring Boot&quot;,&quot;publishedBylines&quot;:[{&quot;id&quot;:185785576,&quot;name&quot;:&quot;Alexander Obregon&quot;,&quot;bio&quot;:&quot;I publish on Medium and use Substack to share recaps, exclusive articles, and a way to support my programming-focused content.&quot;,&quot;photo_url&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8905d669-ef92-4534-9c0c-f486ad372c94_1905x1905.jpeg&quot;,&quot;is_guest&quot;:false,&quot;bestseller_tier&quot;:null}],&quot;post_date&quot;:&quot;2026-09-18T16:42:11.607Z&quot;,&quot;cover_image&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/0ccd589e-df6c-404b-930d-272e878f7720_480x480.png&quot;,&quot;cover_image_alt&quot;:null,&quot;cover_image_crop&quot;:null,&quot;canonical_url&quot;:&quot;https://alexanderobregon.substack.com/p/how-to-run-async-methods-in-spring&quot;,&quot;section_name&quot;:&quot;Java and JVM&quot;,&quot;video_upload_id&quot;:null,&quot;id&quot;:215917685,&quot;type&quot;:&quot;newsletter&quot;,&quot;reaction_count&quot;:0,&quot;comment_count&quot;:0,&quot;publication_id&quot;:4719466,&quot;publication_name&quot;:&quot;Alexander Obregon's Substack&quot;,&quot;publication_logo_url&quot;:&quot;&quot;,&quot;belowTheFold&quot;:false,&quot;youtube_url&quot;:null,&quot;show_links&quot;:null,&quot;feed_url&quot;:null}"></div><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Falexanderobregon.substack.com%2F%3Futm_source%3Dsubstack%26utm_medium%3Demail%26utm_content%3Dshare%26action%3Dshare"><span>Share Alexander Obregon's Substack</span></a></p>]]></content:encoded></item></channel></rss>