
Back in 2020 we looked at Solidity 0.8.0 and I joked that after 0.9 might come 0.10. Six years later we are at 0.8.38, released on October 22, 2026, and 0.9 is still a rumor. The joke aged well. Better than selfdestruct, anyway.
The last four years alone brought 23 minor releases, from 0.8.16 in August 2022 to 0.8.38. None of them was a breaking one, which is exactly why they are easy to miss. A new mapping syntax here, a new keyword there, one opcode per hardfork. Add it all up and the Solidity you write today looks quite different from the Solidity of mid 2022. So let's catch up, release by release where it matters and grouped by topic where it doesn't.
If you only want the raw list, the official changelog has everything. This post is the version with opinions.
The timeline at a glance
| Version | Date | Why you remember it |
|---|---|---|
| 0.8.16 | Aug 2022 | Calldata tuple re-encoding bugfix |
| 0.8.17 | Sep 2022 | Storage write removal bugfix |
| 0.8.18 | Feb 2023 | Paris by default, block.prevrandao, selfdestruct warning, named mapping parameters |
| 0.8.19 | Feb 2023 | User-defined operators |
| 0.8.20 | May 2023 | Shanghai by default, PUSH0 |
| 0.8.21 | Jul 2023 | Relaxed immutables, events from other contracts |
| 0.8.22 | Oct 2023 | Free loop increments, file-level events |
| 0.8.23 | Nov 2023 | verbatim deduplication bugfix |
| 0.8.24 | Jan 2024 | Cancun opcodes: tstore, tload, mcopy, blobs |
| 0.8.25 | Mar 2024 | Cancun by default, MCOPY in generated code |
| 0.8.26 | May 2024 | require with custom errors (via IR), faster optimizer |
| 0.8.27 | Sep 2024 | require with custom errors everywhere, IR caching |
| 0.8.28 | Oct 2024 | transient state variables |
| 0.8.29 | Mar 2025 | Custom storage layout with layout at, experimental EOF |
| 0.8.30 | May 2025 | Prague by default |
| 0.8.31 | Dec 2025 | Osaka by default, clz, a wave of deprecations |
| 0.8.32 / 0.8.33 | Dec 2025 | Storage array bugfix, plus a hotfix the same day |
| 0.8.34 | Feb 2026 | Transient storage clearing bugfix |
| 0.8.35 | Apr 2026 | erc7201 builtin, --experimental flag, SSA CFG code generator |
| 0.8.36 | Jul 2026 | EOF backend removed, Amsterdam support |
| 0.8.37 | Sep 2026 | block.slotnum, more deprecations, three miscompilation fixes |
| 0.8.38 | Oct 2026 | Yul optimizer bugfix, faster compilation |
Yes, that is a lot of table. Let's turn it into something useful.
1. The compiler follows the hardforks
The most predictable pattern of the last four years: Ethereum ships an upgrade, and shortly before or after, Solidity adds the new opcodes and switches its default EVM version. Six times in a row.

Paris (0.8.18)
The Merge replaced block.difficulty with the RANDAO value, and Solidity 0.8.18 gave that value a proper name: block.prevrandao. The old block.difficulty is deprecated and difficulty() is no longer allowed in inline assembly when targeting Paris or later. I wrote a whole deep dive on PREVRANDAO if you want to know why you should be careful with it.
The same release started warning about selfdestruct, following EIP-6049. There is still no replacement, and Cancun later changed its semantics (more on that below). If you have it in a contract you plan to deploy, remove it.
Shanghai (0.8.20)
Shanghai brought PUSH0, a one-byte way to put a zero on the stack. Solidity 0.8.20 uses it automatically and made Shanghai the default EVM version. Cheaper deployment, cheaper runtime, zero work for you.
Zero work, that is, unless you deploy to a chain that didn't support PUSH0 yet. Quite a few L2s took a while, and for months the most common support question was 'why does my deployment fail with invalid opcode'. If you still target an older chain, set --evm-version explicitly. The compiler cannot know where your bytecode is going.
Cancun (0.8.24 and 0.8.25)
Cancun was the big one for Solidity developers. Version 0.8.24 added the new opcodes as Yul builtins:
tstore()andtload()for transient storage (EIP-1153)mcopy()for copying memory (EIP-5656)blobhash()andblobbasefee(), plusblobhash(uint)andblock.blobbasefeein Solidity itself (EIP-4844, EIP-7516)
Cancun also changed selfdestruct (EIP-6780). It now only transfers the Ether balance. Code and storage stay, unless the contract is destroyed in the same transaction that created it. This happens at the network level, so your compiler version doesn't matter here. The warning from 0.8.18 does, though.
One day after Dencun went live on mainnet, 0.8.25 made Cancun the default and started using MCOPY for memory copies in generated code. The savings are modest, since copies from calldata, storage or returndata still work the old way.
Prague (0.8.27 and 0.8.30)
Prague support arrived in 0.8.27 and became the default in 0.8.30, on the day Pectra activated. There were no new opcodes for Solidity to wrap this time. The interesting part was EIP-7702, which lets an externally owned account run contract code. That turned storage layout into a hot topic, and it is the reason custom storage layouts were prioritized for 0.8.29. We'll get there.
Pectra also made calldata more expensive for data-heavy transactions (EIP-7623). The compiler does not try to account for this; the team suggests tuning --optimize-runs instead.
Osaka (0.8.29 and 0.8.31)
Osaka support was added in 0.8.29 and became the default in 0.8.31, on the same day Fusaka activated. The one new opcode for us is CLZ, count leading zeros (EIP-7939). It is available as the Yul builtin clz(x) and is mostly interesting for math libraries. Finding the highest set bit used to take a small binary search in assembly. Now it's one instruction.
function log2Floor(uint256 x) pure returns (uint256 result) {
require(x != 0);
// requires EVM version osaka or later
assembly {
result := sub(255, clz(x))
}
}Amsterdam (0.8.36 and 0.8.37)
Amsterdam is not live yet, but the compiler already supports it as a target. Solidity 0.8.37 added block.slotnum, the beacon chain slot number of the current block (EIP-7843). If you ever tried to approximate epochs from block numbers, like we did in the PREVRANDAO post, you will appreciate it. And 0.8.38 already warns if you use swapn, dupn or exchange as Yul identifiers, because they will become reserved names.
2. Language features you will actually use
Hardforks aside, the language itself grew. Here are the additions I would reach for in a new project today, in the order you are likely to meet them.
Named mapping parameters (0.8.18)
Small change, big readability win. You can finally name the key and the value of a mapping:
mapping(address owner => mapping(address spender => uint256 amount)) public allowance;The names are purely documentation and change nothing about the generated code. Compare it to mapping(address => mapping(address => uint256)) and tell me which one you would rather audit at 2am.
User-defined operators (0.8.19)
User-defined value types exist since 0.8.8, but they were awkward to use because you had to wrap and unwrap everything. Since 0.8.19 you can bind operators to pure free functions:
pragma solidity ^0.8.19;
type UFixed18 is uint256;
using {add as +, mul as *, eq as ==} for UFixed18 global;
function add(UFixed18 a, UFixed18 b) pure returns (UFixed18) {
return UFixed18.wrap(UFixed18.unwrap(a) + UFixed18.unwrap(b));
}
function mul(UFixed18 a, UFixed18 b) pure returns (UFixed18) {
return UFixed18.wrap(UFixed18.unwrap(a) * UFixed18.unwrap(b) / 1e18);
}
function eq(UFixed18 a, UFixed18 b) pure returns (bool) {
return UFixed18.unwrap(a) == UFixed18.unwrap(b);
}
contract Pricing {
function total(UFixed18 price, UFixed18 quantity) external pure returns (UFixed18) {
return price * quantity; // calls mul(price, quantity)
}
}You can define the arithmetic operators + - * / %, the bitwise ones & | ^ ~ and all comparisons. The binding has to be global in the file that defines the type, the functions must be pure free functions, and all parameters have to be the type itself. No +=, no &&, no **, and no redefining operators of built-in types. Which is fine. Nobody needs uint256 + uint256 to mean something surprising.
Immutables without ceremony (0.8.21)
Before 0.8.21, immutables had to be assigned exactly once, and not inside an if, a loop or a try. Now you can read and write them anywhere during construction, as often as you like, as long as it happens outside of functions and modifiers. An immutable you never assign is simply zero.
contract FeeVault {
uint256 public immutable fee;
address public immutable guardian; // never assigned, so address(0)
constructor(uint256 fee_) {
fee = fee_;
if (fee_ > 100) {
fee = 100; // overwriting during construction is fine since 0.8.21
}
}
}Events outside of contracts (0.8.21 and 0.8.22)
Two related changes. Since 0.8.21 you can emit an event that is defined in a contract, interface or library you don't inherit from, using a qualified name. Since 0.8.22 you can define events at file level, right next to your free functions and custom errors. The era of the library Events {} wrapper is over.
event Transfer(address indexed from, address indexed to, uint256 value);
interface IVault {
event Deposited(address indexed who, uint256 amount);
}
contract Token {
function transfer(address to, uint256 value) external {
emit Transfer(msg.sender, to, value); // file-level event, since 0.8.22
emit IVault.Deposited(msg.sender, value); // qualified access, since 0.8.21
}
}Both are also included in the contract's ABI now, which 0.8.20 fixed for events defined outside the emitting contract.
Free loop increments (0.8.22)
Remember the gas golf pattern where you wrote the loop without an increment and did unchecked { ++i; } at the end of the body? You can stop. Since 0.8.22 the compiler drops the overflow check itself when it can prove the counter cannot overflow.

// 2022: the gas golf version
for (uint256 i = 0; i < items.length;) {
total += items[i];
unchecked { ++i; }
}
// since 0.8.22: same bytecode, readable
for (uint256 i = 0; i < items.length; ++i) {
total += items[i];
}The conditions are strict: the condition must be a < comparison with the counter on the left, the right side must fit the counter's type without widening, the counter must be a local built-in integer, the loop expression must be i++ or ++i, and the body must not touch the counter. <= doesn't qualify. The optimization runs even with the optimizer disabled, and you can switch it off in Standard JSON with simpleCounterForLoopUncheckedIncrement: false if you really want to.
require with custom errors (0.8.26 and 0.8.27)
Custom errors exist since 0.8.4, but using them meant writing if (!condition) revert MyError(); every time. Since 0.8.26 require has a third overload that takes an error. It was IR-only at first, and 0.8.27 brought it to the legacy pipeline as well.
error InsufficientBalance(uint256 available, uint256 required);
contract Token {
mapping(address account => uint256 balance) public balance;
function transfer(address to, uint256 amount) external {
require(
balance[msg.sender] >= amount,
InsufficientBalance(balance[msg.sender], amount)
);
balance[msg.sender] -= amount;
balance[to] += amount;
}
}One thing to keep in mind: the error arguments are evaluated whether the condition holds or not, like any function argument. Don't put anything with side effects in there. And if you use named arguments in that position, use 0.8.37 or later; earlier IR builds encoded them in the wrong order, which only affected the revert data but still makes for a confusing debugging session.
Transient storage (0.8.24 to 0.8.28)
This one took three releases. The tstore and tload builtins came with Cancun in 0.8.24, 0.8.27 taught the parser the transient keyword, and 0.8.28 finally generated code for it. Transient storage behaves like storage within one transaction and is reset to zero when the transaction ends. Reads and writes cost 100 gas each. The textbook use case is a reentrancy lock:
pragma solidity ^0.8.28;
contract Guarded {
bool transient locked;
modifier nonReentrant() {
require(!locked, "Reentrancy attempt");
locked = true;
_;
locked = false;
}
function withdraw() external nonReentrant {
// ...
}
}The current restrictions: only value types, no initializer at the declaration, and transient cannot be combined with constant or immutable. Structs, arrays and mappings are not supported yet. The compiler warns on the first tstore in assembly for a reason: a value that survives across calls within a transaction breaks the assumption that batching calls behaves like sending them separately. Reset your transient variables at the end of the call, and don't skip the reset of the lock above to save gas. That would limit your contract to one call per transaction.
Also worth knowing: compiling with --via-ir and using delete on a transient variable had a nasty bug between 0.8.28 and 0.8.33, where the compiler could emit sstore instead of tstore or the other way around. Fixed in 0.8.34. The team found only three affected deployed contracts, so you are probably fine, but check your compiler version.
Custom storage layout (0.8.29, 0.8.31 and 0.8.35)
Here is the feature EIP-7702 made urgent. When an EOA delegates to contract code, every delegate it ever uses shares the same storage. If two of them both put their first variable in slot zero, they will happily overwrite each other. Namespaced storage (ERC-7201) solved this with inline assembly and a lot of boilerplate. Since 0.8.29 the compiler can do it:
pragma solidity ^0.8.35;
contract Wallet layout at erc7201("soliditydeveloper.storage.Wallet") {
address public owner;
uint256 public nonce;
}The layout at specifier moves the base slot of the whole contract, including everything inherited. In 0.8.29 only literal expressions were allowed, 0.8.31 added constants, and 0.8.35 added erc7201(), the first builtin that is evaluated at compile time rather than folded by the optimizer. A few rules: the specifier goes on the most derived contract only, not on abstract contracts, interfaces or libraries, and it doesn't affect transient variables. The compiler also refuses layouts that would push your static variables past the end of storage.
3. The compiler wants a word
Non-breaking releases cannot remove anything. What they can do is warn, and the Solidity team has been using that lever more and more as 0.9.0 gets closer. If your build log has turned yellow lately, this is why.

send() and transfer() (deprecated in 0.8.31). The 2300 gas stipend was enough for a storage read and an event in 2016, and the whole idea depended on opcode prices never changing. They changed. Both functions are now considered an anti-pattern and will be removed in 0.9.0. Use call and check the result, together with a reentrancy guard or checks-effects-interactions:
error TransferFailed();
function payout(address payable to, uint256 amount) internal {
// deprecated since 0.8.31, gone in 0.9.0
// to.transfer(amount);
(bool ok, ) = to.call{value: amount}("");
require(ok, TransferFailed());
}Virtual modifiers (0.8.31). Overriding a modifier in a derived contract turned out to be surprising for users and painful for the compiler. Calling virtual functions from inside a modifier stays allowed.
Comparing contracts (0.8.31). contractA < contractB compiles today and nobody is sure what it should mean. From 0.9.0 you will have to convert to address first.
ABI coder v1 and pragma experimental ABIEncoderV2 (0.8.31 and 0.8.37). Coder v2 has been the default since 0.8.0 and is the only option with --via-ir. The pragma is pure noise now, mostly living on in old forge-std versions.
The memory-safe-assembly comment (0.8.31). Use assembly ("memory-safe") { ... } instead. Same effect, actual syntax.
Old EVM versions. Everything before Constantinople was deprecated in 0.8.22, and 0.8.37 added constantinople, petersburg, istanbul and berlin to the list. If you still compile for Byzantium, the Solidity team would genuinely like to hear from you.
Future keywords (0.8.35). at, error, layout, leave, super, this and transient will become keywords in 0.9.0, and a list of Yul builtins such as tload, mcopy and prevrandao will become reserved. If you named a variable layout, now is a good time to rename it.
block.difficulty and selfdestruct (0.8.18). Still here, still warning, both headed for the exit.
4. The compiler itself
Not every important change is visible in the language. Three storylines ran in the background for the whole four years.
via-IR got fast
Compiling through Yul used to be the slow, memory-hungry option you enabled when the legacy pipeline said 'stack too deep'. Three releases changed that. Version 0.8.26 replaced the default optimizer sequence with a single pass that cut IR compile times by up to 65 percent. Version 0.8.27 started caching optimized Yul for contracts that other contracts create with new, which cut compile times on projects like Uniswap v4 and EigenLayer by more than 40 percent. And 0.8.28 generated Yul ASTs only on demand, cutting memory use by up to 80 percent. If your last experience with via_ir = true was a coffee break, try again.
The long war on stack too deep

Every Solidity developer has met this error, and the 2025 developer survey named it the most common recurring issue. The fight went through three phases.
First, 0.8.21 enabled the stack-to-memory mover by default for via-IR, which moves variables that don't fit on the stack into memory. It solved most cases, at the cost of a few extra memory accesses.
Second, hope arrived in the form of EOF, the EVM Object Format. Version 0.8.29 shipped an experimental EOF backend whose SWAPN and DUPN instructions reach 256 stack slots instead of 16, which would have made the error nearly extinct. Then EOF was dropped from Fusaka late in the process, and 0.8.36 removed the backend again, citing no mainnet support and no realistic prospect of adoption. The consolation prize: SWAPN, DUPN and EXCHANGE are coming anyway, without EOF, as part of the Amsterdam upgrade (EIP-8024). That is why 0.8.38 already reserves their names in Yul.

Third, the current plan: a new code generator based on SSA form control-flow graphs, introduced in 0.8.35 as experimental and improved in every release since. Version 0.8.36 taught it to spill values to memory, so code that fails with stack too deep on both existing pipelines can compile through it. Version 0.8.37 replaced its stack shuffler with a planning one. You can try it with solc --experimental --via-ssa-cfg, and making it the default for --via-ir is the team's stated priority. Experimental means experimental, though: don't ship mainnet bytecode from it yet.
Experimental is now a flag
Speaking of which, 0.8.35 introduced a top-level --experimental flag (or settings.experimental: true in Standard JSON). Nothing experimental works without it, the state is recorded in the metadata, and the team is explicit that these features may change in breaking ways between minor releases. Along the way, several experiments were removed for good: the EWasm backend (0.8.21), the typed Yul dialect (0.8.27), the language server mode and the pragma experimental solidity prototype (both 0.8.37). That last one moved to a separate project, which brings us to the future.
5. The bugs worth knowing about
Minor releases are also where the security fixes land. Most of them affect code almost nobody writes, but a few are worth remembering if you maintain older deployments.
- 0.8.16: ABI re-encoding of calldata tuples could zero the first 32 bytes of a dynamic value when the last component was a static array. Affected structs, external function arguments, events and errors.
- 0.8.17: the Yul optimizer could remove storage writes before an assembly function that conditionally calls
return()orstop(). - 0.8.21:
<expression>.selectordidn't always evaluate the expression in the legacy pipeline, dropping side effects. - 0.8.23: blocks that only differed inside
verbatiminstructions were deduplicated into one. - 0.8.32: clearing or copying arrays that straddle the end of storage could skip writes. Present since 0.1.0, only reachable with unusual layouts. Version 0.8.32 also introduced an internal compiler error on getters of constants, so 0.8.33 followed a few hours later. Skip 0.8.32.
- 0.8.34: the transient storage clearing bug described above.
- 0.8.36: two medium ones. Mutually recursive functions could share spilled memory slots in the IR pipeline, and the warning about layouts too close to the end of storage could reverse the inheritance order, changing constructor and initialization order. Version 0.8.37 fixed a related spill slot collision that went back to 0.7.2.
- 0.8.37:
deleteon one element of a memorybytesarray zeroed the whole 32-byte word in the legacy pipeline. That one existed in every version up to 0.8.36. - 0.8.38: the Yul optimizer could keep stale knowledge about memory and storage addresses after a variable was reassigned, from 0.8.19 to 0.8.37. Comparisons across Sourcify-verified contracts found no changed bytecode, so it is rare in practice.
The pattern here is the usual one: upgrade regularly, and read the list of known bugs before you pin a version for years.
6. What's next: 0.9 and Core Solidity
The deprecation wave tells you a breaking 0.9.0 release is being prepared, but there is no date yet. The bigger news is what the team calls Core Solidity: a new frontend for the same compiler with algebraic data types, pattern matching with exhaustiveness checking, generics, traits and compile-time evaluation. It compiles to Yul, so it shares the optimizer and the new SSA code generator with today's language, now called Classic Solidity. The two are meant to coexist in one project.
Here is a taste from the pattern matching post. Instead of a struct with an enum and five fields that are only valid in some combinations, a payment is one of three shapes, and the compiler refuses a match that forgets one:
data Payment =
Native(address, word)
| ERC20(address, address, address, word)
| ERC721(address, address, address, tokenid);
function processPayment(payment : Payment) {
match payment {
| Native(to, amount) =>
transfer(to, amount);
| ERC20(token, from, to, amount) =>
transferFromERC20(token, from, to, amount);
| ERC721(token, from, to, tokenId) =>
transferFromERC721(token, from, to, tokenId);
}
}Core Solidity is a prototype. Checked arithmetic, events, custom errors and view don't exist yet, inheritance is deliberately gone in favor of traits and modules, and the team says plainly that it is not for mainnet. You can play with it in the online playground, and there is a workshop at Devcon in Mumbai on November 5, 2026. I'm curious. Pattern matching in a smart contract language is overdue.
Should you upgrade?
Same checklist as in 2020, with better odds this time.
- Are you below 0.8.22? Upgrading gets you free loop optimizations,
PUSH0and file-level events without touching your code. Check that your target chain supportsPUSH0, or pin--evm-version. - Are you using
--via-ir? Go straight to 0.8.37 or later. That skips the transient storage clearing bug, the spill slot collisions and the misorderedrequirearguments. - Are you between 0.8.28 and 0.8.33 and use
deleteon a transient variable? Upgrade today. - Do your warnings mention
transfer,send, virtual modifiers orABIEncoderV2? Fix them now while they are warnings. They will be errors in 0.9.0. - Are you deploying to an L2? Double check its EVM version before trusting the compiler default, which is Osaka now.
Four years, 23 releases, no breaking changes, and still a very different language at the end of it. Not bad for a project that was supposed to hit 1.0 eventually.
Happy Solidity coding!




Join the conversation
Comments are hosted by Disqus and load only when you choose to enable them.