Hibernate 7.4 New Features

Hibernate 7.4 introduced several improvements that simplify loading a page of data along with their associated child collection, historical data access, and audit logging.

The article will focus on the following features:

  • Limits and Fetch Joins: How Hibernate 7.4 improves working with paginated queries that include fetched associations.
  • History and Audit Tables: How the new capabilities support querying entity state across time and working with historical data.

You can check out the sample code for this article in this GitHub repository.

Limits and Fetch Joins

One common requirement in data-driven applications is loading a page of parent entities along with an associated child entity collection. For example, suppose an application has an Order entity with a Set<OrderItem> collection, and we want to load the first few orders together with their order items.

List<Order> orders = session
        .createSelectionQuery(
            "select o from Order o join fetch o.items order by o.id",
            Order.class
        )
        .setMaxResults(10)
        .getResultList();

In Hibernate versions before 7.4, applying a limit to a query that used a collection fetch join could not be safely pushed down to the database. Because each Order may have multiple OrderItem rows, limiting the SQL result directly could cut off part of an order’s item collection. To avoid returning incomplete collections, Hibernate loaded all matching rows from the database and applied pagination in memory at the application layer.

That behavior was correct, but it could be expensive. A query intended to load only 10 orders might still read many more rows if the table contained a large number of orders and order items.

Before Hibernate 7.4, the generated SQL would look like the following:

select
    o1_0.id, i1_0.order_id, i1_0.id, i1_0.product_code,
    i1_0.quantity, o1_0.order_number, o1_0.status
from
    orders o1_0
        join
    order_items i1_0
    on o1_0.id=i1_0.order_id

As you can see, the limit(pagination) is not applied at the SQL query level. So, it will load all the orders and their associated order_items, which could be a very expensive operation and may result in OutOfMemoryException.

You can see a WARNING logged by Hibernate as follows:

[WARN] HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory

One option to prevent Hibernate performing pagination in memory is by setting the following property:

hibernate.query.fail_on_pagination_over_collection_fetch=true

By configuring this property, Hibernate throws an exception instead of performing pagination in memory.

Hibernate 7.4 fixes this problem by using nested queries. Instead of applying the limit directly to the joined result set, Hibernate first determines the limited set of parent entity identifiers and then fetches the associated collection for only those parent rows. 

This allows pagination to happen in the database while still returning complete items collections for each selected Order.

With Hibernate 7.4, the SQL will be generated as follows:

select
        o1_0.id, i1_0.order_id, i1_0.id, i1_0.product_code,
        i1_0.quantity, o1_0.order_number,o1_0.status 
    from
        (select
            o1_0.id, o1_0.order_number, o1_0.status 
        from
            orders o1_0 
        where
            exists(select
                1 from order_items i1_0 
            where
                o1_0.id=i1_0.order_id) 
        offset
            ? rows 
        fetch
            first ? rows only) o1_0(id, order_number, status) 
    join
        order_items i1_0 
            on o1_0.id=i1_0.order_id

This improvement makes fetch joins more practical for paginated screens, such as an order listing page that displays each order with its line items, without forcing the application to load the full result set first.

History and Audit Tables

Hibernate 7.4 adds built-in support for temporal history tables and audit tables. Both features help track changes to entity data, but they serve slightly different use cases: history tables let us query the state of an entity at a point in time, while audit tables record the sequence of changes that happened to an entity.

Consider the following Product entity:

@Entity
@Table(name = "products")
class Product {
    //fields id, code, name, price
}

History Tables

To enable temporal history for Product, annotate the entity with @Temporal and optionally specify the history table name using @Temporal.HistoryTable.

@Entity
@Table(name = "products")
@Temporal
@Temporal.HistoryTable(name="products_history")
class Product {
    //fields id, code, name, price
}

With this mapping, Hibernate stores previous versions of product rows in the products_history table. The table includes the entity columns plus two temporal columns: effective, which marks when a version became active, and superseded, which marks when that version was replaced.

products_history table:

id code name price effective superseded
2251 P1000 Product-1000 40.00 2026-05-15 08:21:39.949001 +00:00 null
2301 P1001 Product-1001 90.00 2026-05-15 08:22:24.765883 +00:00 2026-05-15 08:22:24.778067 +00:00
2301 P1001 Product-1001 100.00 2026-05-15 08:22:24.778067 +00:00 null

We can get the Product entity data at a given point of time as follows:

Instant someTime = ...
try (var session = sessionFactory.withOptions().asOf(someTime).open()) {
    var product = session.find(Product.class, productId);
    
}

This makes temporal queries feel like normal entity lookups while Hibernate resolves the correct historical row behind the scenes.

Hibernate offers several different strategies(NATIVE, SINGLE_TABLE, HISTORY_TABLE) for mapping temporal entities. For more info check out the Temporal data section.

Audit Tables

Previously, Hibernate-based applications typically used the separate Hibernate Envers library for auditing entity changes. Hibernate 7.4 brings audit table support into Hibernate ORM itself, so applications can use auditing features natively without adding Envers for this use case.

Audit support is enabled by adding @Audited and can be mapped to a custom table using @Audited.Table.

@Entity
@Table(name = "products")
@Audited
@Audited.Table(name="products_aud_log")
class Product {
    //fields id, code, name, price
}

When auditing is enabled, Hibernate writes one row per change into the audit table. Unlike the history table, the audit table focuses on recording what operation happened and when.

id code name price rev revtype
2001 P1002 Product-1002 90.00 2026-05-13 14:58:17.505775 +00:00 0
2001 P1002 Product-1002 100.00 2026-05-13 14:58:17.518194 +00:00 1

The rev values are the timestamps at which the change happened. The revtype values are represented using ModificationType enum as follows:

public enum ModificationType {
    /**
    * Creation, encoded as 0
    */
    ADD,
    /**
    * Modification, encoded as 1
    */
    MOD,
    /**
    * Deletion, encoded as 2
    */
    DEL
}

For more info check out the Audit logs section.

Summary

Most of the applications use pagination to show a list of resources, and we used to write custom logic to load paginated data along with the associated child collection. Now this is being handled at the framework level itself. Also, we used to rely on external libraries like Envers to implement auditing, which is now provided by Hibernate itself.

Hibernate 7.4 brings practical improvements that address real problems in JPA/ Hibernate-based applications. Whether we are optimizing pagination query behavior or tracking historical data, Hibernate 7.4 reduces the amount of custom infrastructure needed and provides better support out of the box without requiring additional libraries.

Go ahead and explore these new features using this GitHub repository.

What Does It Actually Take for an IDE to Understand Rust?

Disclaimer: This article was created using AI-based writing and communication companions. With their help, the core topics of this rich and nuanced livestream were distilled into a compact blog post format.

How do Rust IDEs understand code? That was the central question explored in a recent RustRover livestream featuring Lukas Wirth, Rust engineer at Zed and team lead for rust-analyzer, and Vlad Beskrovny, engineer on RustRover at JetBrains. Rather than comparing editors or debating preferences, the discussion focused on what actually happens under the hood when an IDE analyzes Rust code.

If you missed the livestream, you can watch the full recording on JetBrains TV. Below is a structured recap of the key questions and insights from the session.

Q1. How did Lukas and Vlad get started with Rust?

Before diving into compiler frontends and IDE architecture, the livestream started with a more personal question: how did they first get into programming? Interestingly, both Lukas and Vlad mentioned Minecraft modding in Java as one of their earliest programming experiences. Lukas started writing Java mods for Minecraft while still in school, eventually teaching himself Rust when entering university.

“I taught myself Rust when I entered university and basically stopped using any other language at that point.”

Lukas Wirth, Rust IDEs creator

Lukas Wirth
rust-analyzer

Vlad discovered Rust around 2014, but did not seriously start writing it until joining JetBrains and working on the IntelliJ Rust plugin, the predecessor to RustRover.

Q2. Why do Rust IDEs reimplement parts of the compiler?

To provide features like completion, go to declaration, semantic highlighting, and refactorings, Rust IDEs effectively need to understand the language almost as deeply as the compiler itself.

“To provide smart features such as completion and go to declaration, we have to reimplement half of the compiler, basically the whole compiler frontend.

Vlad Beskrovny
RustRover

So why not simply reuse the compiler directly? Compilers optimize for throughput:
how efficiently they can transform source code into binaries.

IDEs optimize for latency:
how quickly they can answer small interactive questions while the developer is typing.

“I typed a dot, and how quickly can I see the completion variants? At this point, I don’t care about other function bodies, about the rest of the files, about any other files in the project. I just want my completion to appear instantly.”

That difference fundamentally changes the architecture. Compilers tend to process code eagerly and sequentially: parse everything, resolve everything, expand everything, infer everything. IDEs instead try to compute only the minimum information necessary for the current interaction.

Q3. How did Rust tooling evolve from RLS to rust-analyzer and RustRover?

The livestream also revisited the history of Rust tooling. Before rust-analyzer, Rust’s primary language server was RLS, the Rust Language Server.

RLS attempted to build IDE functionality directly on top of the compiler using “save analysis”. The compiler produced large JSON outputs containing semantic information, which the language server later queried. In practice, this approach struggled with latency and incomplete code.

“It was nearly impossible to implement completion this way because rustc barely works with incomplete code, which is almost always the case when a user needs completion.”

RLS was eventually replaced by rust-analyzer, which adopted a more incremental architecture focused specifically on IDE responsiveness.

The discussion also touched on the origins of IntelliJ Rust, the project that eventually evolved into RustRover. Interestingly, both rust-analyzer and IntelliJ Rust originated from work started by Alex Kladov, although the projects later evolved in very different architectural directions.

Q4. Why is name resolution in Rust so difficult?

Rust’s module graph is cyclic, which means IDEs cannot resolve names incrementally in the same simple way many other languages can.

Lukas demonstrated this using a chain of nested reexports where resolving a single symbol required tracing through several modules, aliases, and glob imports before reaching the original declaration. To support this workflow, IDEs repeatedly:
• collect modules
• resolve imports
• expand macros
• collect newly generated items
• and repeat the process until no unresolved symbols remain

This repeated process is often described as “fix point iteration”. And unfortunately for tooling authors, macros make this process even more complicated.

Q5. Why are procedural macros such a challenge for IDEs?

In theory, a procedural macro is simply a function that transforms tokens into other tokens. In practice, procedural macros are dynamically loaded libraries that can:
• access the filesystem
• read environment variables
• execute arbitrary code
• crash processes
• or terminate execution entirely

“Proc macros are kind of more than that. They are dynamic linked libraries. They can do whatever they want on the host system.”

Lukas Wirth, Rust IDEs creator

That creates major challenges for IDEs. If a procedural macro crashes inside the IDE process itself, it could terminate the entire IDE session. To avoid that, both rust-analyzer and RustRover isolate procedural macro execution into separate processes and communicate through custom protocols.

“If the proc macro actually hard crashes or exits the process, in the worst case we just lose a proc macro server that we can spin up again. But at least the IDE keeps running.”

Lukas Wirth, Rust IDEs creator

Q6. Why is Rust type inference difficult to replicate?

Rust’s type system introduces another layer of complexity. The good news for tooling authors is that Rust type inference is mostly local to function bodies, which makes incremental analysis possible. The bad news is that Rust contains countless special inference rules and edge cases that IDEs must replicate precisely.

“The issue with Rust type inference is that it has way too many arbitrary rules. Literally thousands of arbitrary rules we have to meticulously replicate.”

During the livestream, he demonstrated several examples where tiny structural changes completely changed whether code compiled successfully.

Some of these behaviors even depend on the internal order in which expressions are processed during inference. These details directly affect editor features like:
• completion
• diagnostics
• navigation
• inspections
• and inlay hints

And unlike compilers, IDEs must provide useful semantic results even while the code is incomplete.

Q7. How does RustRover analyze large Rust projects?

RustRover begins by building a project model from Cargo metadata and crate dependencies. It then indexes project files using PSI, or Program Structure Interface, an abstraction layer used throughout JetBrains IDEs. Vlad said that PSI can be backed by either:
• full syntax trees
• or lightweight “stubs” containing only declarations and signatures

This allows RustRover to avoid fully parsing every file eagerly, significantly reducing memory usage and improving responsiveness. The indexing system itself uses a MapReduce-style architecture where files are processed independently and incrementally.

One especially interesting detail was that during indexing, RustRover can skip parsing function bodies in some phases because stubs only require declarations and signatures.

“During indexing we don’t parse function bodies at all.”

Instead, RustRover can move through the file structure efficiently by lexing and counting braces, which significantly speeds up indexing. The broader point was that modern IDEs cannot be purely lazy. At some point, they still need eager analysis.

“The true art of an IDE design is to draw this line in the right place”

Q8. How does rust-analyzer approach the same problem differently?

While RustRover relies heavily on indexing infrastructure, rust-analyzer uses a query-driven architecture inspired by the Rust compiler itself. Semantic operations are modeled as memoized dependency-tracked queries using the Salsa framework.

“All the semantically interesting bits in rust-analyzer are put behind so-called queries.”

Lukas Wirth, Rust IDEs creator

This allows rust-analyzer to invalidate and recompute only the precise semantic information affected by an edit. Unnecessary dependencies can accidentally invalidate computations on every keystroke, making performance optimization surprisingly subtle.

Lukas explained several layers of garbage collection and memory optimization used inside rust-analyzer, including:
• LRU query caches
• symbol interning
• and custom mark-and-sweep tracing collectors for type internals

Q9. How does IDE analysis connect to debugging?

Vlad demonstrated how RustRover integrates semantic analysis directly into debugging workflows. RustRover’s debugger uses a customized LLDB integration together with IDE-generated MIR representations for evaluated expressions. 

When developers evaluate expressions during debugging sessions, RustRover generates MIR for the relevant expression graph, serializes it, and interprets it through the debugger backend.

It was a strong example of how modern IDEs increasingly behave less like text editors and more like full semantic environments built around the language itself. The livestream ended with a quick audience question about debugging asynchronous Rust workflows and whether RustRover could eventually visualize Tokio async tasks similarly to Rider.

Q10. Do Rust tooling authors secretly hate Rust?

“Usually a feature that makes the language more pleasant to use tends to introduce a lot more complexity on the implementation side.

Lukas Wirth, Rust IDEs creator

Vlad also added:

“I love Rust. How can you otherwise explain why I spent like nine years of my life going through all these complexities?

At the same time, both acknowledged that some Rust features arrived early in the language’s history before the ecosystem fully understood their long-term tooling implications, especially around procedural macros.

If you are interested in Rust tooling, compiler internals, IDE architecture, or language design tradeoffs, the full discussion between Lukas Wirth and Vlad Beskrovny is worth watching.

Watch Full Video

TeamCity 2026.1.1 Is Now Available

Today we’re rolling out the first bug-fix for TeamCity On-Premises 2026.1 servers. This update addresses over 20 issues and performance issues, including:

  • Build agent alternate IP addresses ignored by TeamCity;
  • Damaged Rake plugin;
  • Failing uploads to S3 buckets;
  • .NET builds with “Exists” agent requirements cannot find a compatible build agent to run.

See TeamCity 2026.1.1 Release Notes for the complete list of resolved issues.

Why update?

Staying up to date with minor releases ensures your TeamCity instance benefits from the following:

  • Performance improvements.
  • Better compatibility with integrations.
  • Faster, more stable builds.
  • Enhanced security for your workflows.

Compatibility

TeamCity 2026.1.1 shares the same data format as all 2026.1.x releases. You can upgrade or downgrade within this series without the need for backup and restoration.

How to upgrade

  1. Use the automatic update feature in your current TeamCity version.
  2. Download the latest version directly from the JetBrains website.
  3. Pull the updated TeamCity Docker image.

Need help?

Thank you for reporting issues and providing feedback! If you have questions or run into any problems, please let us know via the TeamCity Forum or Issue Tracker.

Happy building!

JetBrains Academy – May Digest

Hey! 

This month’s list is short, but every item is worth your time. 

Apply for one of up to 40 JetBrains Foundation scholarships for the CSAI BSc program by June 9, try a new AI tools course for developers, discover a program that brings hands-on coding practice into JetBrains IDEs, and read about the value of productive struggle in learning to code.

How We Use AlphaEvolve to Make Complex IDE Algorithms Faster

AlphaEvolve is a Google DeepMind algorithm-discovery system that uses Gemini to generate, test, and refine possible algorithm improvements. Its job is not to answer questions; it searches for faster ways to solve complex algorithmic problems. We tried it on a narrow but important part of IntelliJ-based IDEs: indexing, the background work that makes navigation, search, completion, refactorings, inspections, and other code insight available after a project opens.

That makes indexing speed a simple metric to say out loud and a hard metric to improve. It depends on the language, the framework, the shape of the project, background IDE work, and the storage layer underneath the indexes. Small changes can disappear in noise. Some wins are real in a microbenchmark and invisible in a full IDE run.

We already invest a lot of engineering time here, and that manual performance work continues. The experiment described in this post was not a replacement for engineering judgement, profiling, code review, or product validation. It was a test of an additional search method: could Google DeepMind’s AlphaEvolve help us find useful optimization candidates in code that had already been worked on for years?

Result snapshot

We first tested the generated candidates on a synthetic benchmark, then validated the most promising ones in a full IDE environment.

Integration test, in seconds, lower is better: Kotlin Spring Petclinic on modified IntelliJ IDEA 2026.2 nightly builds. Baseline 17.4 ± 0.5s. Solution 1 measured 16.6 ± 0.2s in our run table.

15-20%
Synthetic performance score improvement seen in most AlphaEvolve sessions with 50+ iterations.
17.4s
Full IDE baseline for Kotlin Spring Petclinic, with ±0.5s variability.
16.6s
Best measured candidate, reported as ±0.2s.
2 / 5
Generated candidates that showed a statistically significant integration-test improvement.

Interactive measurement dashboard

Use the tabs to move between the end-to-end result, individual runs, and the experiment funnel. For time and score charts, lower is better.



Google DeepMind describes AlphaEvolve in its AlphaEvolve preview blog as a Gemini-powered coding agent for designing algorithms by combining LLM-generated code with automated evaluators. For this experiment, that evaluator was our performance and correctness setup.

The target: a B-tree in the indexing stack

We chose the B-tree at the foundation of our index implementation. The starting point was not a naive prototype. It was a deeply optimized piece of infrastructure where manual exploration had become expensive. Even a plausible change takes time to write, review, and validate, and a wrong change can be fast for the wrong reason.

The engineering description was deliberately plain: the original algorithm was essentially a classic B-tree, and the proposed candidates were mostly improved B-tree variants with optimizations around edge cases. That is the kind of problem AlphaEvolve is well suited for. There is code to change. There is a clear score. There are tests that reject broken ideas.

The loop: generate, score, validate

AlphaEvolve optimizing an instance of the “Tammes problem”.

We gave AlphaEvolve an internal performance test suite for the storage layer. The suite is synthetic. It does not use real customer projects. It writes and reads synthetic data so that candidate changes can be tested quickly and repeatedly.

The score was based on the sum of median results across our mid-sized benchmarks. Unit tests acted as the correctness check. With that setup, most AlphaEvolve sessions with more than 50 iterations produced a 15-20% improvement in the synthetic performance score.

That was encouraging, but it was not enough. Synthetic benchmarks are useful because they are controlled. Users do not run controlled benchmarks. They run full IDEs, with background processes, language services, and project-specific behavior running at the same time. So we took the best generated candidates into integration tests.

For the full IDE step, the team used Kotlin Spring Petclinic and modified IntelliJ IDEA 2026.2 nightly builds. The reported baseline for total end-to-end indexing time was 17.4 ± 0.5 seconds. Out of five generated candidates, two showed statistically significant improvements, with reproducible results below 16.8 seconds.

Claim boundaries



Most 50+ iteration sessions improved the synthetic performance score by 15-20%. This is the strongest claim about the autonomous optimization loop because the benchmark was the optimization target.

What changed in the numbers

Our end-to-end run table contains two measured candidates. Solution 1 produced a mean result of 16.6 seconds, reported as ±0.2 seconds. Against the 17.4-second baseline, that is about 0.8 seconds faster, or roughly a 4.6% reduction in this integration scenario.

Solution 2 is useful for the story too, although not because it won the full IDE test. It measured at 17.5 ± 0.4 seconds, which is effectively baseline in this scenario. Both candidates improved the fast synthetic benchmark, but only one of these two showed a user-visible end-to-end improvement in the integration measurements.

That distinction matters. A performance workflow that only celebrates synthetic wins will eventually ship misleading claims. A workflow that pairs autonomous search with full IDE validation has a better chance of finding changes users can feel.

AlphaEvolve can change how we approach complex performance work. It turns optimizations that were once too time-consuming to explore into candidates we can test routinely. Engineers still own the benchmark, review, and release decision. The search space is what gets smaller.

Dmitrii Batkovich, Director of Engineering for IntelliJ Platform

What we measure next

The next step is product validation. The team plans to check whether improvements show up in the Mega Index metric, an internal KPI used to track indexing performance and user experience, especially whether users are more satisfied with the indexing process. That is the right bar. A faster internal benchmark is useful. A faster full IDE test is better. A better user experience is the result that matters.

For us, the important lesson is not that AlphaEvolve magically made indexing fast. It did something more practical. It helped generate and rank low-level optimization ideas in a space where manual exploration is slow. JetBrains engineers supplied the problem, the tests, the measurement discipline, and the judgement. AlphaEvolve expanded the search.

Acknowledgements

This project was a collaboration between the JetBrains team, including Denis Shiryaev and Dmitrii Batkovich, and the AI for Science and account teams at Google Cloud, including Anant Nawalgaria, Skander Hannachi, Kartik San, Laurynas Tamulevičius, Nicolas Stroppa, and Artemiy Yashin.

Algorithmic Theming Engines: Building Self-Correcting Color Systems With `contrast-color()`

The HTTP Archive Web Almanac has been tracking color contrast failures for years. The numbers have barely moved. After half a decade of design system tooling, accessibility linters, and entire JavaScript libraries dedicated to computing readable text colors, 70% of websites still fail basic WCAG contrast checks in 2025. The WebAIM Million paints an even grimmer picture — 83.9% of homepages flagged for low contrast text in 2026, up from 79.1% in 2025. The rate improves by maybe a few percentage points per year on one benchmark and actually gets worse on another. That’s not progress — that’s proof that relying on runtime JavaScript for something this fundamental doesn’t scale across the open web. We didn’t need better libraries. We’ve needed better CSS.

The contrast-color() function is that better CSS. One declaration. The browser runs the contrast math during style computation, before the page paints, and hands you the right text color. No library, no build step, no hydration flash.

Note: If you’ve seen it called color-contrast() in older articles and spec drafts — that name was changed, and the old syntax no longer works in any browser.

What It Does (And What It Doesn’t)

The Level 5 version is simple. You give it a color. It gives you back black or white, whichever has more contrast against your input.

.button {
  background-color: var(--brand-color);
  color: contrast-color(var(--brand-color));
}

Change --brand-color to neon green, text goes black. Change it to midnight navy, text goes white. Swap themes at runtime via JavaScript and the text adapts instantly — no event listeners, no recalculation.

A few things to know about the current version:

  • It returns a <color>, not a number. You get an actual color value (black or white), you can use anywhere CSS accepts a color.
  • Black or white only, for now. Candidate color lists and target ratios are planned for Level 6.
  • No keywords. If you’ve seen max in older blog posts, that was stripped from the spec. Using it will silently break your declaration.
  • As mentioned above, this function used to be called color-contrast() in early drafts. That name is dead — the CSSWG renamed it to follow the convention that CSS functions are named for what they return. color-mix() returns a color. contrast-color() returns a color. The old color-contrast() name sounded like it returned a contrast ratio (a number like 4.5), which was misleading. Any tutorial from 2021–2023 showing color-contrast() syntax won’t work in current browsers.

The Spec Split: Level 5 Versus Level 6

This function lives across two specifications. That’s unusual and worth understanding.

CSS Color Level 5 defines what browsers ship today. One color in, black or white out. The algorithm is deliberately marked “UA-defined”, meaning the browser decides what math to use internally. Right now, every engine uses WCAG 2.x relative luminance. But that “UA-defined” label isn’t accidental — it’s a planned escape hatch.

You’ll see APCA (Accessible Perceptual Contrast Algorithm) mentioned a lot in this context. APCA models how human eyes actually perceive contrast, factoring in font weight, spatial frequency, and ambient light — a genuine improvement over the WCAG 2.x formula. By not locking “use WCAG 2.x” into the Level 5 spec, browser vendors could swap to APCA later without breaking any existing code. If the spec had shipped with a wcag2() keyword as the default, every site using it would’ve been stuck on the old math permanently.

But APCA’s future is far less certain than the hype suggests. Adrian Roselli’s “WCAG3 Contrast as of April 2026” lays out the current situation clearly: APCA was pulled from the WCAG 3 working draft in mid-2023 after failing to gain enough Working Group support. The WCAG 3 spec currently says the contrast algorithm is “yet to be determined,” and the standard itself may not be finalized until 2030 or later. Roselli also filed a Chromium issue in May 2024 asking for the “Advanced Perceptual Contrast Algorithm” experiment flag to be removed from DevTools entirely, arguing that the implementation is outdated and risks misleading developers into thinking APCA is further along — or more official — than it actually is. That issue is still open.

None of this means APCA is dead. The research behind it is peer-reviewed and substantive, and its creator has noted that colors passing APCA guidelines greatly exceed WCAG 2 minimums in the vast majority of cases. But right now, there is no guarantee APCA will be the algorithm that replaces WCAG 2.x — and that uncertainty matters for contrast-color(). If a different algorithm wins out, or if WCAG 3 adopts something entirely new, the “UA-defined” label means browsers can adapt without breaking your code. It also means the Level 6 features — candidate color lists, target ratios, the tbd-fg/tbd-bg keywords — are all designed around an algorithm that may or may not materialize in its current form.

CSS Color Level 6 adds the extended syntax — candidate color lists and target contrast ratios:

/* Level 6 future syntax — not shipping yet */
color: contrast-color(var(--bg) tbd-bg wcag2(aa), #1a1a2e, #e2e8f0, #fbbf24);

The browser would evaluate each candidate left to right and pick the first that meets the 4.5:1 AA threshold. The tbd-fg and tbd-bg keywords indicate whether the base color is foreground or background, which matters for directional contrast models like APCA. This is all Working Draft territory — doubly so given APCA’s uncertain status. Use the Level 5 version for now.

Browser Support

This one’s in better shape than most new CSS features. All three major engines have shipped it in stable releases: Chrome 147 (April 2026), Firefox 146, and Safari 26.0. It reached Baseline Newly Available status in April 2026. Check caniuse for the full version matrix. All three engines pass the Web Platform Tests for contrast-color(), which means the edge cases (e.g., tie-breaking logic, color space conversion, syntax parsing) behave the same across browsers.

The raw global support percentage on caniuse looks low, but that mostly reflects enterprise browsers and people who never update. If you’re reading this, your browser almost certainly supports it already.

Progressive enhancement is straightforward using @supports:

.card {
  background: var(--bg);
  color: #fff;
  text-shadow: 0 0 4px rgb(0 0 0 / 0.8);
}

@supports (color: contrast-color(red)) {
  .card {
    color: contrast-color(var(--bg));
    text-shadow: none;
  }
}

Older browsers get white text with a dark shadow for legibility. Supporting browsers get the native calculation. Nobody sees broken text.

One thing to watch for: automated accessibility scanners (Lighthouse, Axe, etc.) can’t evaluate text-shadow. They only look at the computed color against background-color. So the fallback will still get flagged as a contrast failure in CI/CD pipelines, even if the shadow makes the text perfectly legible to human eyes. If your team runs automated a11y checks, you may need to allowlist that specific rule or add a comment explaining why the flag is a false positive.

A note on PostCSS:

There’s a plugin (@csstools/postcss-contrast-color-function) that evaluates contrast-color() at build time. It works for static colors like contrast-color(#ff0000). But the moment you use a custom property — contrast-color(var(--bg)) — the plugin can’t help because it has no access to runtime values. If your theming is dynamic (which is the whole point of doing this), skip the polyfill and rely on @supports.

The Gotchas

It Doesn’t Guarantee Perceptual or AAA Compliance

This can trip people up: “I used the contrast function, so my site passes accessibility checks now, right?”

Mathematically? Usually yes. There is a persistent myth that for certain “mid-tone” backgrounds, both black and white fail the standard WCAG 4.5:1 AA ratio. That’s mathematically false. Under the WCAG 2.x relative luminance formula, there is absolutely no background color where both pure black and pure white fail AA. One (or both) will always pass.

Take #2277d3 (a medium blue). It sits right on a mathematical knife-edge where both black and white actually pass AA (both hit roughly 4.58:1). contrast-color() will hand you whichever has the slight mathematical edge.

But here is the actual gotcha: the WCAG 2.x math has known perceptual blind spots. That same #2277d3 with black text mathematically passes AA, but to human eyes, it can be incredibly difficult to read. contrast-color() gives you mathematical compliance, which is great for automated audits, but that doesn’t always equal perceptual accessibility. (This is exactly why APCA exists and why the spec was designed to let browsers swap algorithms later.)

Furthermore, if you’re aiming for the stricter WCAG AAA standard (7.0:1), a true dead zone does exist. For backgrounds with a luminance between roughly 10% and 30%, neither black nor white will hit 7:1. In those cases, contrast-color() can’t save you — it just hands you the “least bad” failing option.

Transitions Snap, Not Fade

If you’re animating a background from white to black on hover:

.btn {
  background-color: #fff;
  color: contrast-color(#fff); /* black */
  transition: background-color 1s, color 1s;
}
.btn:hover {
  background-color: #000;
  color: contrast-color(#000); /* white */
}

The background fades smoothly over one second. But because the Level 5 output is a discrete value (black or white), the text color can’t be interpolated. It snaps.

And here is the visual gotcha: the snap doesn’t happen halfway through. If you’ve been building themes for a while, you probably have muscle memory from the old Sass days, where we checked if lightness($bg) > 50%. That relied on HSL lightness, where 50% is the geometric midpoint.

But WCAG 2.x relative luminance is a non-linear scale. Under the WCAG formula, the mathematical tipping point — where black and white have identical contrast against the background — actually occurs at approximately 18% relative luminance (specifically ~17.9%).

Because of that, the visual behavior during a white-to-black fade is heavily skewed. The text doesn’t snap in the middle. It stays black for the vast majority of the animation, only snapping to white at the very tail-end of the transition when the background gets extremely dark. It’s a jarring, late hard cut.

You might assume transition-behavior: allow-discrete fixes this. It doesn’t. allow-discrete does not fix the jarring visual experience because it cannot interpolate a binary output; it only shifts the timing of the hard snap to the 50% mark of the animation duration. If you need smooth text color transitions, you’ll have to layer color-mix() or manage the crossfade yourself.

Tie Goes To White

If the background is a perfect middle gray where both black and white produce identical contrast ratios, the spec has a hardcoded tiebreaker: white wins. Not a big deal in practice, but worth knowing if you’re debugging gray palettes and the text isn’t doing what you expect.

Gradients And Images Are Out

The function takes a flat <color> value. You can’t pass it a gradient or a url(). contrast-color(linear-gradient(...)) is a parse error. If your background is a photo or a complex gradient, you still need JavaScript or manually color-pick for overlay text.

Transparent Colors Are Composited First

Pass a semi-transparent color, and the browser blends it against an assumed opaque canvas (usually white) before running the contrast math. It’s not ignoring your alpha channel — it’s compositing it. But the result might surprise you if you expected the function to “see through” to whatever’s actually behind the element.

Windows High Contrast Mode

If a user enables Windows High Contrast, the forced-colors: active media query kicks in and the browser aggressively overwrites author-defined colors. contrast-color() bows out — forced system colors like CanvasText take over completely. You don’t need to write manual media queries to undo your contrast logic; the browser handles the hierarchy.

Combining It With Other Color Functions

Black or white sounds limiting, but once you feed that output into other CSS color functions, you can build an entire component palette off a single custom property.

Brand-Tinted Contrast With Relative Color Syntax

Pure black text on a vibrant card looks fine. Pure white on a coral card can feel flat. What if the contrast text was a very dark or very light tint of the background color instead?

Kevin Hamer explored related territory in his CSS-Tricks piece “Approximating contrast-color() With Other CSS Features”, where he used OKLCH lightness and round() to approximate the black/white switch without contrast-color() — essentially oklch(from <color> round(1.21 - l) 0 0). That’s a polyfill strategy: get the binary light/dark decision working in browsers that don’t support the native function yet. What we’re doing here is different — we start with contrast-color()’s native output and then enrich it by injecting the background’s own hue:

.card {
  --bg-hue: 260; /* Indigo */
  --bg: oklch(0.6 0.1 var(--bg-hue));
  background: var(--bg);

  /* Pull L from the black/white contrast color,
     but inject subtle chroma and the background's hue */
  color: oklch(from contrast-color(var(--bg)) l 0.05 var(--bg-hue));
}

When contrast-color() returns white, l is 1 (full lightness). When it returns black, l is 0. By pulling the background’s hue back in and adding a touch of chroma, you get text that reads as a deep dark indigo or a pale icy indigo instead of generic black/white. Hamer’s approach gives you the black/white decision without browser support; this one takes the decision the browser already made and gives it personality.

Fair warning: By tweaking the lightness and chroma of the black/white output, you can push a borderline contrast ratio into failing territory. Always run your tinted output through an accessibility linter before shipping.

Also worth noting: This example chains two very modern features — contrast-color() and oklch(from ...). If either one isn’t supported, the entire declaration fails silently. Your @supports block needs to test for both:

@supports (color: contrast-color(red)) and (color: oklch(from red l c h)) {
  /* Safe to use both */
}

Softened Contrast With color-mix()

Similar idea, simpler API. Mix the sharp black/white output back into the background to soften it:

.alert {
  --bg: var(--alert-color);
  background: var(--bg);

  /* 80% contrast, 20% background = softer but readable */
  color: color-mix(in oklch, contrast-color(var(--bg)) 80%, var(--bg));

  /* 40% contrast for a subtle border */
  border: 1px solid
    color-mix(in oklch, contrast-color(var(--bg)) 40%, var(--bg));
}

One custom property driving text, border, and potentially box-shadow or outline. Change --alert-color and the entire component recalculates.

This pattern also works well for ::placeholder text, which is a common pain point in dynamic theming. Placeholder text should be readable but visually softer than the input’s main text — color-mix() with contrast-color() gets you there:

input {
  --bg: var(--input-bg);
  background: var(--bg);
  color: contrast-color(var(--bg));
}

input::placeholder {
  color: color-mix(in oklch, contrast-color(var(--bg)) 50%, var(--bg));
}

50% mix gives you a muted but legible placeholder that adapts automatically to whatever background the input sits on.

Theme-Aware Contrast With light-dark()

For apps that support system light/dark mode:

:root {
  color-scheme: light dark;
  --surface: light-dark(#fff, #121212);
}

.component {
  background: var(--surface);
  color: contrast-color(var(--surface));
}

When the operating system switches to dark mode, --surface resolves to #121212, and contrast-color() returns white. No media queries, no JavaScript theme detection. The whole chain resolves natively.

What You Can Remove From Your Bundle

The practical payoff: every one of these libraries existed because CSS couldn’t do contrast math. If you’re only using them for readable-text-color selection, you can pull them out of your runtime entirely:

Library Size What it did
chroma-js ~14 kB Color parsing, luminance calc, readable color selection
polished ~11 kB readableColor() for styled-components
tinycolor2 ~5 kB Hex parsing, WCAG contrast ratio math

You might still need these for generating complex color scales, but the contrast-for-readability use case is now covered natively.

Beyond bundle size, there’s a performance angle that’s easy to overlook. Those JavaScript libraries don’t just cost you network bytes — they run on the main thread. Every time a theme changes or a component mounts with a dynamic background, your JS has to parse the color, compute luminance, decide black or white, and write the result back to the DOM. That’s main-thread work competing with layout, event handlers, and everything else your app is doing. contrast-color() moves all of that into the browser’s native style computation phase — heavily optimized C++ that runs before paint. For apps with lots of themed components, that’s a real difference in responsiveness.

There’s also a subtle bug that goes away: hydration flash. In React or Vue SSR apps, the server renders HTML without JavaScript. The client then hydrates, running JS to calculate contrast and inject the correct text color. For a brief window between initial paint and hydration, the text is either invisible or the wrong color. Moving contrast into CSS eliminates that entirely — the browser resolves the correct color during the initial paint, before JavaScript loads.

What We Used To Do

For context on what this replaces:

Sass era. You’d write a function that checked lightness($bg) > 50% and returned black or white at compile time. Worked for static themes. Completely useless for user-picked colors, CMS palettes, or dark mode, because the output was baked into the CSS file and could never change at runtime.

The variable toggle hack. When CSS custom properties shipped, people got creative. GitHub used a version of this for their issue label picker — splitting colors into --r, --g, --b channels, calculating Rec.709 luminance inside calc(), multiplying by negative infinity, and clamping to 0 or 1. It worked. It was also unreadable, unmaintainable, and would break silently if you got one parenthesis wrong. (Kevin Hamer’s OKLCH-based approximation is the most elegant version of this lineage — cleaner math, better perceptual alignment — but it’s still a workaround for a function that now ships natively.)

contrast-color() replaces all of these approaches with a single function call. And because the spec lets browsers upgrade the underlying algorithm, your code won’t need to change if and when a successor to WCAG 2.x contrast math lands — whether that’s APCA or something else entirely.


That 70% failure rate was never about developers refusing to care about contrast. It was about the distance between caring and shipping — the library, the build step, the runtime calculation, the hydration flash, the one component someone forgot to wire up. Every gap in that chain was a spot where accessibility quietly dropped out.

contrast-color() doesn’t make developers care more. It makes caring cost nothing.

Oracle ORA-00031 Error: Causes and Solutions Complete Guide

ORA-00031: Session Marked for Kill — What It Means and How to Fix It

ORA-00031 occurs when a DBA issues ALTER SYSTEM KILL SESSION but Oracle cannot terminate the target session immediately. Instead, Oracle marks the session as “KILLED” and waits for it to reach a safe termination point — typically after completing a rollback or releasing OS-level resources. This is less of a hard error and more of a transitional state that every Oracle DBA will eventually encounter.

Top 3 Causes

1. Large Transaction Rollback in Progress

When you kill a session mid-transaction, Oracle must roll back all uncommitted changes to preserve data integrity. The larger the transaction, the longer the session stays in KILLED status.

-- Check rollback progress for KILLED sessions
SELECT s.sid,
       s.serial#,
       s.username,
       t.used_ublk AS undo_blocks,
       t.used_urec AS undo_records
FROM   v$session s
JOIN   v$transaction t ON s.taddr = t.addr
WHERE  s.status = 'KILLED';

2. Unresponsive or Disconnected Client

If the client network connection is broken or the client process has hung, Oracle cannot deliver the kill signal. The session lingers in KILLED state until the OS-level connection finally times out.

-- Find the OS process ID (SPID) for stuck KILLED sessions
SELECT s.sid,
       s.serial#,
       s.username,
       s.status,
       p.spid AS os_pid,
       s.machine,
       s.program
FROM   v$session s
JOIN   v$process p ON s.paddr = p.addr
WHERE  s.status = 'KILLED';

3. OS-Level I/O or Resource Wait

Sessions blocked at the OS level (disk I/O stall, memory pressure, storage issues) cannot respond to Oracle’s internal kill signal. In these cases, only an OS-level process termination will resolve the problem.

-- Identify what the session was waiting on before being killed
SELECT sid,
       serial#,
       status,
       event,
       wait_class,
       seconds_in_wait
FROM   v$session
WHERE  status = 'KILLED';

Quick Fix Solutions

Option 1 — Use the IMMEDIATE keyword (recommended first step)

-- Standard kill (asynchronous)
ALTER SYSTEM KILL SESSION '123,456';

-- Immediate kill (forces faster termination)
ALTER SYSTEM KILL SESSION '123,456' IMMEDIATE;

Option 2 — OS-level kill (last resort)

-- Get the SPID first
SELECT p.spid
FROM   v$session s
JOIN   v$process p ON s.paddr = p.addr
WHERE  s.sid     = 123
AND    s.serial# = 456;
# Linux/Unix: hard kill using SPID from above query
kill -9 <spid>

⚠️ Warning: Use OS-level kill -9 only after confirming no active rollback is in progress. Interrupting a rollback at the OS level can lead to block corruption.

Prevention Tips

1. Set IDLE_TIME in user profiles to automatically disconnect sessions that have been inactive too long — reducing the need for manual kills in the first place.

-- Create a profile that disconnects idle sessions after 30 minutes
CREATE PROFILE app_profile LIMIT
    IDLE_TIME    30
    CONNECT_TIME 480;

ALTER USER app_user PROFILE app_profile;

2. Use batch commits for large DML operations to minimize rollback size, so that if a session must be killed, the rollback completes quickly and ORA-00031 resolves faster.

-- Batch delete with intermediate commits
BEGIN
    LOOP
        DELETE FROM large_table
        WHERE  status = 'EXPIRED'
        AND    ROWNUM <= 5000;

        EXIT WHEN SQL%ROWCOUNT = 0;
        COMMIT;
    END LOOP;
END;
/

Related Errors

Error Code Description
ORA-00028 Session successfully killed — seen by the killed session’s user
ORA-00030 No such session — invalid SID/Serial# combination used in kill command
ORA-01013 User requested cancel of current operation

Pro Tip: Before reaching for kill -9, always check v$transaction to see if a rollback is actively running. Patience is often the safest fix — let Oracle finish the rollback cleanly rather than risk data block corruption with a forced OS kill.

Two survival systems, two empathy modes

Here are two scenes. They look unrelated. They’re not.

Scene 1

Two people at a café, talking about a restaurant they want to try. A stranger walking past stops: “That place closed six months ago. The one on the corner is better.” A brief nod, and they walk on.

The two people exchange a glance, taken aback. Why did that person stop? What did they want?

A few steps away, the stranger is also confused. They had useful information. They shared it. Why did these people react so strangely?

Scene 2

A colleague is visibly stressed, describing a difficult situation at work. One friend pulls their chair closer, puts a hand on their arm: “That sounds really hard.” Another opens their laptop: “I found something that might help — HR has a process for exactly this, I’ll send you the link.”

The colleague leans into the first. Glances uncertainly at the second.

The second person doesn’t understand why sitting close and saying “that sounds hard” counts as helping. You haven’t solved anything. The first doesn’t understand why anyone would respond to distress with links.

Both scenes end the same way: people on both sides convinced they did the right thing, confused by the other’s reaction. The mismatch is mutual and invisible from the inside.

Two survival instincts, two empathy systems

For many autistic people, information is a survival mechanism. Uncertainty is threat, missing information is a vulnerability, and the drive to correct and share runs below conscious awareness. Empathy, expressed through that system, looks like giving someone what keeps you safe: accurate information, solutions, resources. The social preamble before sharing — announcing yourself, softening the approach — doesn’t arise as a concept. Why would useful information require an introduction?

For many neurotypical people, social safety is a survival mechanism. Group cohesion and reading others accurately are what keep people safe. Empathy, expressed through that system, looks like presence: mirroring distress, making someone feel held, maintaining the social fabric. An uninvited approach from a stranger bypasses the protocol that signals safe intent — and that protocol isn’t a nicety, it’s the unlock code. Without it, the content can’t land regardless of how useful it is.

The social preamble is as foreign a concept to the autistic person as the direct approach is unsettling to the neurotypical person. The information response is as opaque to the neurotypical person as emotional attunement is to the autistic person. Neither protocol is natural to the other system. The incomprehension runs in both directions, with equal depth.

Milton’s double empathy problem

In 2012, autistic researcher Damian Milton described what he called the double empathy problem: cross-neurotype communication difficulties aren’t a deficit on one side, they’re a mismatch between two coherent systems that are mutually opaque to each other. Historically, the autistic side has been asked to compensate, the neurotypical system treated as the default rather than as one particular survival logic among two.

What these two scenes show is that both sides are trying to care for the other, each in the only language their system knows, and neither is being received as care.

That’s not a deficit. That’s two survival systems, built for different threats, each expressing empathy in the only currency it has.

Adding a full docker setup to the Filament Mastery Starters

For a while, my starter kits didn’t include any Docker configuration. The foundation was solid with auth, roles, MFA, Horizon, Logs Viewer, but the deployment side was left to whoever cloned the project.

That was a deliberate choice at first. Docker setups vary a lot depending on the infrastructure: some people use a reverse proxy, others have Cloudflare in front, some run on bare metal, others on managed platforms. I didn’t want to ship something that would need to be ripped out immediately.

But over time I changed my mind. Here’s why and what the process taught me.

The problem with “just configure it yourself”

Leaving deployment out of a starter kit sounds reasonable. In practice, it means every project starts with the same 4-6 hours of Docker work that never really changes.

Multi-stage Dockerfile. PHP-FPM config. Nginx with HTTPS. PostgreSQL and Redis wired up. Horizon and the scheduler running as proper services. Healthchecks everywhere so Docker knows when things are actually ready.

None of it is so complicated. But it’s time-consuming, easy to get subtly wrong, and almost identical from one project to the next.

Once I admitted that, the question wasn’t whether to include Docker, it was how to do it in a way that’s actually useful without being too opinionated about production infrastructure.

What I ended up building

The setup I settled on covers the full local development stack:

  • A multi-stage Dockerfile : separate stages for Composer dependencies, Node assets, and the final PHP-FPM image. Keeps the production image lean.
  • Nginx with HTTP-to-HTTPS redirect and a self-signed certificate for local dev, already included, no setup needed.
  • PostgreSQL and Redis as services with proper healthchecks.
  • Horizon and the scheduler as dedicated services, not crammed into the main app container.
  • A bootstrap service that runs php artisan migrate --force before the app starts.

The Dockerfile uses three stages to keep the final image as lean as possible:

FROM php:8.4-fpm-alpine AS composer_builder
# Install extensions, run composer install
# ...

FROM node:24-alpine AS node_builder
# Install npm dependencies, build Vite assets
# ...

FROM php:8.4-fpm-alpine AS php_fpm
# Final image, only what's needed to run
# Copy vendor/ from composer_builder
# Copy public/build/ from node_builder
# ...

Each stage does one thing. The final image never contains Composer, Node, or dev dependencies.

The full Dockerfile architecture with extensions, non-root user, Xdebug for local dev, is covered here: Production-Ready Docker Setup for Laravel Filament.

The bootstrap service

Running migrations on deploy is one of those things that sounds simple until you’ve had a deployment fail because the app started before the database was ready.

The pattern I use is a dedicated bootstrap service that exits when migrations succeed. The app service depends on it, so the app simply doesn’t start until migrations are done.

bootstrap:
  image: ${APP_IMAGE}:${APP_VERSION}
  command: php artisan migrate --force
  depends_on:
    db:
      condition: service_healthy
    # ...

app:
  image: ${APP_IMAGE}:${APP_VERSION}
  depends_on:
    bootstrap:
      condition: service_completed_successfully
    # ...

horizon:
  image: ${APP_IMAGE}:${APP_VERSION}
  command: php artisan horizon
  depends_on:
    bootstrap:
      condition: service_completed_successfully
    # ...

scheduler:
  image: ${APP_IMAGE}:${APP_VERSION}
  command: php artisan schedule:work
  # ...

No SSH. No manual commands. No “did someone run the migrations?” before going live.

A word of honesty on this pattern: it works well for single-instance deployments, one VPS, one app container. If you’re running multiple replicas or need strict zero-downtime guarantees, this approach has limits. Multiple bootstrap services running simultaneously can conflict, and the app will be briefly unavailable during migration. In those cases, migrations should be handled at the CI/CD pipeline level, before containers are deployed. That’s a topic worth a dedicated article, and it’s on the roadmap.

The full compose setup, volumes, healthchecks, network config, restart policies, is covered in detail here: Production-Ready Docker Compose for Laravel Filament.

What I deliberately left out

I didn’t include a production-ready Nginx config. Not because it’s hard to write, but because production environments vary too much.

Some projects sit behind Traefik. Others use Cloudflare in front. Some have real Let’s Encrypt certificates managed externally, others use internal PKI. Shipping a “production” Nginx config that works for one setup and silently breaks another isn’t helpful.

What I do ship is a docker-compose.example.yaml, clearly labeled as a starting point, not a drop-in solution. The dev config is complete and ready to use. The production side is documented, commented, and deliberately left for the developer to adapt.

I think that’s the right balance for a starter kit. Give people enough to be productive immediately, without making decisions that belong to them.

What this changes for the starters

Both the Backend Starter and the Multipanel Starter now include the full Docker setup.

Copy the repo, copy .env.example, define your app key, rundocker compose up -d --build, then php artisan backend:setup, and you have a running panel with auth, roles, MFA, Horizon, and Logs Viewer, all in one go.

It’s a meaningful improvement over “configure Docker yourself.” Not because Docker is complicated, but because those 4-6 hours are better spent on the actual project.

Both starters are available with the Filament Mastery membership.

As always, if something doesn’t work the way you’d expect or you’d approach it differently, let me know in the comments.

The Upcoming Sunset of DataSpell

After careful consideration, we have made the difficult decision to sunset DataSpell as a standalone product.

DataSpell was created to focus on the needs of data science and analytics professionals within the JetBrains ecosystem. It allowed us to build and refine a dedicated experience for working with Jupyter notebooks, data exploration, and analytical workflows. These improvements have been successfully integrated into PyCharm, where they can benefit a much broader audience. 

Today, we’ve reached a point where maintaining a separate product is no longer the most effective path forward. This choice is part of JetBrains’ broader strategy to build a more cohesive ecosystem around the workflows users rely on most. By consolidating these capabilities in PyCharm, we can move faster, simplify the overall experience, and deliver more value in one place.

What’s next for DataSpell users?

Starting from September 1, 2026, we will help existing DataSpell users transition to PyCharm, where data science and analytics workflows will continue to be actively developed and improved.

To make this transition as smooth as possible, eligible DataSpell customers will be able to transition to PyCharm at no additional cost. Current DataSpell users also maintain access through a fallback license.

The transition will happen in stages:

  • May 28, 2026: DataSpell will be deprecated as a standalone product, meaning it will no longer be possible to purchase new DataSpell subscriptions. Existing licenses will continue to work.
  • September 1, 2026: Eligible DataSpell subscriptions will be converted to PyCharm Pro subscriptions. Eligible customers will receive JetBrains AI Credits matching the value of their remaining DataSpell subscription period.
    • Personal customers who already have licenses for both PyCharm Pro license and DataSpell when the conversion happens on September 1 will not experience any changes. Their DataSpell licenses will remain valid until the original expiration date.

Please contact us if you need any assistance as you prepare for the transition on September 1.

For organizational commercial customers

We plan to convert organizations’ DataSpell licenses to PyCharm ones on September 1, 2026.

Since DataSpell subscriptions include bundled AI Credits, in addition to the license conversion, eligible organizational customers will receive JetBrains AI Credits matching the value of the remaining DataSpell subscription period. 

Organizational customers will also retain access to DataSpell through a fallback license, giving their teams more time to complete the transition to PyCharm.

For personal commercial customers

On September 1, 2026, your DataSpell license will automatically be converted to a PyCharm Pro license for the remaining subscription period, and eligible customers will receive JetBrains AI Credits matching the value of the remaining DataSpell subscription period.

You will also continue to have access to DataSpell through a fallback license, so you’ll have time to complete your transition to PyCharm.

If you already have PyCharm Pro and DataSpell subscriptions, your DataSpell license will remain unchanged and will not be converted.

All Products Pack holders will retain access to their DataSpell licenses.

For free license holders

If you are using DataSpell with a free license, you can continue using DataSpell through your current license terms. But we recommend moving to PyCharm for your data science workflows. Eligible users can apply for a free PyCharm Pro license through the existing JetBrains programs for students, teachers, open-source contributors, and other supported groups.

Continuing with your data workflows in JetBrains IDEs

Although DataSpell is being retired as a standalone product, its key workflows will remain available within other JetBrains tools.

PyCharm Pro provides built-in support for:

  • Jupyter notebooks
  • SQL and databases 
  • Python data analysis workflows

You can continue working with notebooks, data visualization, and Python-based data processing within the JetBrains ecosystem without losing the capabilities you rely on today. You can learn more about PyCharm’s support for data science and data analysis here. 

Thank you

We want to express our deepest gratitude to the DataSpell community – your feedback, support, and engagement have been invaluable.

While this marks the end of DataSpell as a standalone product, it also represents a new chapter where data workflows continue to evolve within the broader JetBrains ecosystem.

The JetBrains team

FAQ

What will be the last DataSpell release?
The final release of DataSpell will be 2026.1. There will be no further regular updates beyond potential vulnerability fixes, and support will gradually be phased out.

What alternatives are available for DataSpell users?
Most DataSpell workflows are available in PyCharm Pro, which supports Jupyter notebooks, Python data analysis, and interactive development workflows. If you mainly work with databases, you may want to consider using DataGrip.

What should I do if I have questions or reservations about the automatic license migration?
If you have questions or reservations about automatic conversion, it’s important that you contact a JetBrains representative by August 31, 2026, at the latest.

What should I do if I recently purchased a DataSpell license?
You will also be automatically converted to PyCharm Pro licenses. Also our standard refund policy applies.

What can I do if I need more information?
Please contact JetBrains Sales Support or your existing JetBrains Sales representative for additional help. You can also get more information about licenses and terms in JetBrains legal information portal.