January 09, 2026

Preparing for Solidity 0.9.0: What’s Deprecated and What to Do About It

Solidity 0.9.0 is coming, and it’s going to break things.

If you’ve compiled your contracts with Solidity 0.8.31 or later, you’ve probably noticed new deprecation warnings. These aren’t suggestions, they’re advance notice that features you might be using will stop working entirely when 0.9.0 drops.

This is the first breaking release since 0.8.0 landed in December 2020. That’s four years of accumulated changes, and the Solidity team is using this release to clean house.

The good news: you have time to prepare. The deprecation warnings are live now, the breaking changes are documented, and migration is straightforward if you start early.

This article covers what’s being removed, why it matters, and exactly how to update your code.

1. transfer() and send() Are Dead

This is the big one. If you’re still using .transfer() or .send() to move ETH, your code will not compile in Solidity 0.9.0.

Why They’re Being Removed

Both transfer() and send() were originally designed as "safe" ways to send ETH because they forward only 2,300 gas to the recipient. The idea was that this gas limit would prevent reentrancy attacks, the recipient wouldn't have enough gas to call back into your contract.

The problem: gas costs change.

When EIP-1884 increased the cost of SLOAD from 200 to 800 gas in the Istanbul upgrade, contracts that previously worked fine with 2,300 gas suddenly started failing. A simple receive function that logged an event or updated a state variable would run out of gas.

The 2,300 gas stipend is now considered a footgun, not a safety feature. It breaks legitimate contracts and doesn’t reliably prevent reentrancy anyway (there are other attack vectors).

What the Deprecation Looks Like

If you compile this with Solidity 0.8.31+:

function withdraw() external {
    payable(msg.sender).transfer(address(this).balance);
}

You'll see:

Warning: Using "transfer" is deprecated. Use "call" instead, and be aware of
re-entrancy vulnerabilities.

How to Fix It

Replace transfer() and send() with call():

Before:

function withdraw() external {
    payable(msg.sender).transfer(amount);
}

After

function withdraw() external {
    (bool success, ) = payable(msg.sender).call{value: amount}("");
    require(success, "Transfer failed");
}

But What About Reentrancy?

Yes, call() forwards all available gas, which means the recipient can execute arbitrary code — including calling back into your contract.

You need to handle this explicitly. Two options:

Option 1: Checks-Effects-Interactions Pattern

Update state before making external calls:

function withdraw() external {
    uint256 amount = balances[msg.sender];
    
    // Effect: Update state first
    balances[msg.sender] = 0;
    
    // Interaction: External call last
    (bool success, ) = payable(msg.sender).call{value: amount}("");
    require(success, "Transfer failed");
}

Option 2: Reentrancy Guard

Use OpenZeppelin’s ReentrancyGuard or implement your own:

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract Vault is ReentrancyGuard {
    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        balances[msg.sender] = 0;
        
        (bool success, ) = payable(msg.sender).call{value: amount}("");
        require(success, "Transfer failed");
    }
}

Option 3: Transient Storage Reentrancy Guard (Gas Efficient)

If you’re targeting post-Dencun chains, use transient storage for the lock:

contract Vault {
    bytes32 constant LOCK_SLOT = keccak256("reentrancy.lock");
    
    modifier nonReentrant() {
        assembly {
            if tload(LOCK_SLOT) { revert(0, 0) }
            tstore(LOCK_SLOT, 1)
        }
        _;
        assembly {
            tstore(LOCK_SLOT, 0)
        }
    }
    
    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        balances[msg.sender] = 0;
        
        (bool success, ) = payable(msg.sender).call{value: amount}("");
        require(success, "Transfer failed");
    }
}

This saves ~5,000 gas per call compared to storage-based guards.

2. Virtual Modifiers Are Going Away

In Solidity 0.9.0, you will no longer be able to mark modifiers as virtual or override them in derived contracts.

What’s Changing

Currently, this is valid:

contract Base {
    modifier onlyOwner() virtual {
        require(msg.sender == owner, "Not owner");
        _;
    }
}

contract Derived is Base {
    modifier onlyOwner() override {
        require(msg.sender == owner || msg.sender == admin, "Not authorized");
        _;
    }
}

In 0.9.0, this will not compile.

Why It’s Being Removed

Virtual modifiers create confusing inheritance behavior. When a function uses a modifier, and that modifier is overridden in a derived contract, it’s not always clear which version of the modifier executes, especially in complex inheritance hierarchies.

The Solidity team decided that the added complexity isn’t worth the flexibility. Functions can still be overridden, which covers most legitimate use cases.

How to Fix It

Option 1: Use a Virtual Function Inside the Modifier

contract Base {
    modifier onlyAuthorized() {
        require(_isAuthorized(msg.sender), "Not authorized");
        _;
    }
    
    function _isAuthorized(address account) internal virtual returns (bool) {
        return account == owner;
    }
}

contract Derived is Base {
    function _isAuthorized(address account) internal override returns (bool) {
        return account == owner || account == admin;
    }
}

The modifier stays fixed, but the authorization logic it calls can be customized.

Option 2: Accept the Fixed Modifier

In many cases, you don’t actually need to override modifiers. If your derived contracts need different access control, consider using a different modifier entirely:

contract Base {
    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }
}

contract Derived is Base {
    modifier onlyAdmin() {
        require(msg.sender == admin, "Not admin");
        _;
    }
    
    function adminFunction() external onlyAdmin {
        // ...
    }
}

3. ABI Coder v1 Is Finally Gone

ABI coder v1 has been deprecated since Solidity 0.8.0, where v2 became the default. In 0.9.0, v1 is being removed entirely.

What’s Different in v2

ABI coder v2 supports:

  • Nested dynamic arrays (uint256[][])

  • Structs in function parameters and return values

  • More consistent encoding/decoding behavior

If you’re explicitly using v1:

pragma abicoder v1;

This will not compile in 0.9.0.

How to fix it

Step 1: Remove the pragma

Delete any pragma abicoder v1; declarations.

Step 2: Check for Encoding Differences

In most cases, the switch is transparent. However, if you’re doing low-level ABI encoding/decoding or interacting with contracts that expect v1 encoding, you may need to verify compatibility.

Key differences to watch:

  • v2 validates dynamic types more strictly on decoding

  • v2 handles nested arrays differently

  • Some edge cases around padding and encoding order

Step 3: Test Your External Integrations

If your contract calls other contracts or is called by off-chain systems that manually encode calldata, verify that the encoding matches expectations.

// Test that external calls still work
function testExternalCall() external {
    bytes memory data = abi.encodeWithSignature(
        "someFunction(uint256[][])", 
        nestedArray
    );
    (bool success, ) = targetContract.call(data);
    require(success, "Call failed");
}

4. memory-safe-assembly Comment Syntax Removed

If you’re using inline assembly with the memory-safe annotation via a NatSpec comment, you need to switch to the proper syntax.

What’s Changing

Old (deprecated):

/// @solidity memory-safe-assembly
assembly {
    // ...
}

New (required):

assembly ("memory-safe") {    // ...}

Why It Matters

The memory-safe annotation tells the optimizer that your assembly block follows Solidity's memory model. This enables certain optimizations that would otherwise be disabled globally when any assembly is present.

The comment-based syntax was a temporary workaround for backwards compatibility. Now that the proper syntax has been available for several versions, the workaround is being removed.

How to Fix It

Find and replace all instances:

# Find all files with the old syntaxgrep -r "@solidity memory-safe-assembly" contracts

Then update each one:

Before:

function efficientHash(bytes memory data) internal pure returns (bytes32 result) {    /// @solidity memory-safe-assembly    assembly {        result := keccak256(add(data, 32), mload(data))    }}

After:

function efficientHash(bytes memory data) internal pure returns (bytes32 result) {    assembly ("memory-safe") {        result := keccak256(add(data, 32), mload(data))    }}

5. Contract Type Comparisons Deprecated

Comparing variables of different contract types is now deprecated and will be removed in 0.9.0.

What’s Changing

This will not compile in 0.9.0:

contract A {}contract B {}function compare(A a, B b) external pure returns (bool) {    return a == b; // Deprecated: comparing different contract types}

Why It’s Being Removed

Comparing two different contract types is almost always a bug. If A and B are different contracts, comparing them directly doesn't make semantic sense, you're comparing addresses, but the type system suggests you're comparing contract instances.

The Solidity team decided to make this a compile error to catch bugs early.

How to Fix It

If you actually need to compare the underlying addresses, cast explicitly:

Before:

function isSameAddress(A a, B b) external pure returns (bool) {    return a == b;}

After:

function isSameAddress(A a, B b) external pure returns (bool) {    return address(a) == address(b);}

This makes the intent clear: you’re comparing addresses, not contract instances.

6. How to Check Your Codebase

Don’t wait for 0.9.0 to find out what breaks. Use 0.8.31+ now to surface all deprecation warnings.

Step 1: Update Your Compiler

In your foundry.toml:

[profile.default]solc_version = "0.8.33"

Or in hardhat.config.js:

module.exports = {  solidity: "0.8.33",};

Step 2: Compile and Capture Warnings

# Foundryforge build 2>&1 | grep -i "deprecated"# Hardhatnpx hardhat compile 2>&1 | grep -i "deprecated"

Step 3: Address Each Warning

Step 4: Run Your Test Suite

After each change, verify nothing broke:

forge test

Step 5: Consider a Phased Rollout

If you’re maintaining deployed contracts:

  1. Fix warnings in development: update your codebase now

  2. Deploy new versions: new deployments use updated code

  3. Migrate when ready: existing deployments can migrate via upgrades (if upgradeable) or redeployment

Conclusion

Solidity 0.9.0 is cleaning up years of accumulated technical debt. The deprecations make sense, they remove footguns, simplify the language, and encourage better patterns.

The key is to start now:

  1. Upgrade to 0.8.31+ to see all deprecation warnings

  2. Fix the high-priority items (transfer/send) first

  3. Test thoroughly after each change

  4. Don’t wait for 0.9.0 to break your CI pipeline

If you’re maintaining a large codebase or want a second opinion on your migration strategy, we’re happy to help.

Solidity 0.9.0 is coming, and it’s going to break things.

If you’ve compiled your contracts with Solidity 0.8.31 or later, you’ve probably noticed new deprecation warnings. These aren’t suggestions, they’re advance notice that features you might be using will stop working entirely when 0.9.0 drops.

This is the first breaking release since 0.8.0 landed in December 2020. That’s four years of accumulated changes, and the Solidity team is using this release to clean house.

The good news: you have time to prepare. The deprecation warnings are live now, the breaking changes are documented, and migration is straightforward if you start early.

This article covers what’s being removed, why it matters, and exactly how to update your code.

1. transfer() and send() Are Dead

This is the big one. If you’re still using .transfer() or .send() to move ETH, your code will not compile in Solidity 0.9.0.

Why They’re Being Removed

Both transfer() and send() were originally designed as "safe" ways to send ETH because they forward only 2,300 gas to the recipient. The idea was that this gas limit would prevent reentrancy attacks, the recipient wouldn't have enough gas to call back into your contract.

The problem: gas costs change.

When EIP-1884 increased the cost of SLOAD from 200 to 800 gas in the Istanbul upgrade, contracts that previously worked fine with 2,300 gas suddenly started failing. A simple receive function that logged an event or updated a state variable would run out of gas.

The 2,300 gas stipend is now considered a footgun, not a safety feature. It breaks legitimate contracts and doesn’t reliably prevent reentrancy anyway (there are other attack vectors).

What the Deprecation Looks Like

If you compile this with Solidity 0.8.31+:

function withdraw() external {
    payable(msg.sender).transfer(address(this).balance);
}

You'll see:

Warning: Using "transfer" is deprecated. Use "call" instead, and be aware of
re-entrancy vulnerabilities.

How to Fix It

Replace transfer() and send() with call():

Before:

function withdraw() external {
    payable(msg.sender).transfer(amount);
}

After

function withdraw() external {
    (bool success, ) = payable(msg.sender).call{value: amount}("");
    require(success, "Transfer failed");
}

But What About Reentrancy?

Yes, call() forwards all available gas, which means the recipient can execute arbitrary code — including calling back into your contract.

You need to handle this explicitly. Two options:

Option 1: Checks-Effects-Interactions Pattern

Update state before making external calls:

function withdraw() external {
    uint256 amount = balances[msg.sender];
    
    // Effect: Update state first
    balances[msg.sender] = 0;
    
    // Interaction: External call last
    (bool success, ) = payable(msg.sender).call{value: amount}("");
    require(success, "Transfer failed");
}

Option 2: Reentrancy Guard

Use OpenZeppelin’s ReentrancyGuard or implement your own:

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract Vault is ReentrancyGuard {
    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        balances[msg.sender] = 0;
        
        (bool success, ) = payable(msg.sender).call{value: amount}("");
        require(success, "Transfer failed");
    }
}

Option 3: Transient Storage Reentrancy Guard (Gas Efficient)

If you’re targeting post-Dencun chains, use transient storage for the lock:

contract Vault {
    bytes32 constant LOCK_SLOT = keccak256("reentrancy.lock");
    
    modifier nonReentrant() {
        assembly {
            if tload(LOCK_SLOT) { revert(0, 0) }
            tstore(LOCK_SLOT, 1)
        }
        _;
        assembly {
            tstore(LOCK_SLOT, 0)
        }
    }
    
    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        balances[msg.sender] = 0;
        
        (bool success, ) = payable(msg.sender).call{value: amount}("");
        require(success, "Transfer failed");
    }
}

This saves ~5,000 gas per call compared to storage-based guards.

2. Virtual Modifiers Are Going Away

In Solidity 0.9.0, you will no longer be able to mark modifiers as virtual or override them in derived contracts.

What’s Changing

Currently, this is valid:

contract Base {
    modifier onlyOwner() virtual {
        require(msg.sender == owner, "Not owner");
        _;
    }
}

contract Derived is Base {
    modifier onlyOwner() override {
        require(msg.sender == owner || msg.sender == admin, "Not authorized");
        _;
    }
}

In 0.9.0, this will not compile.

Why It’s Being Removed

Virtual modifiers create confusing inheritance behavior. When a function uses a modifier, and that modifier is overridden in a derived contract, it’s not always clear which version of the modifier executes, especially in complex inheritance hierarchies.

The Solidity team decided that the added complexity isn’t worth the flexibility. Functions can still be overridden, which covers most legitimate use cases.

How to Fix It

Option 1: Use a Virtual Function Inside the Modifier

contract Base {
    modifier onlyAuthorized() {
        require(_isAuthorized(msg.sender), "Not authorized");
        _;
    }
    
    function _isAuthorized(address account) internal virtual returns (bool) {
        return account == owner;
    }
}

contract Derived is Base {
    function _isAuthorized(address account) internal override returns (bool) {
        return account == owner || account == admin;
    }
}

The modifier stays fixed, but the authorization logic it calls can be customized.

Option 2: Accept the Fixed Modifier

In many cases, you don’t actually need to override modifiers. If your derived contracts need different access control, consider using a different modifier entirely:

contract Base {
    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }
}

contract Derived is Base {
    modifier onlyAdmin() {
        require(msg.sender == admin, "Not admin");
        _;
    }
    
    function adminFunction() external onlyAdmin {
        // ...
    }
}

3. ABI Coder v1 Is Finally Gone

ABI coder v1 has been deprecated since Solidity 0.8.0, where v2 became the default. In 0.9.0, v1 is being removed entirely.

What’s Different in v2

ABI coder v2 supports:

  • Nested dynamic arrays (uint256[][])

  • Structs in function parameters and return values

  • More consistent encoding/decoding behavior

If you’re explicitly using v1:

pragma abicoder v1;

This will not compile in 0.9.0.

How to fix it

Step 1: Remove the pragma

Delete any pragma abicoder v1; declarations.

Step 2: Check for Encoding Differences

In most cases, the switch is transparent. However, if you’re doing low-level ABI encoding/decoding or interacting with contracts that expect v1 encoding, you may need to verify compatibility.

Key differences to watch:

  • v2 validates dynamic types more strictly on decoding

  • v2 handles nested arrays differently

  • Some edge cases around padding and encoding order

Step 3: Test Your External Integrations

If your contract calls other contracts or is called by off-chain systems that manually encode calldata, verify that the encoding matches expectations.

// Test that external calls still work
function testExternalCall() external {
    bytes memory data = abi.encodeWithSignature(
        "someFunction(uint256[][])", 
        nestedArray
    );
    (bool success, ) = targetContract.call(data);
    require(success, "Call failed");
}

4. memory-safe-assembly Comment Syntax Removed

If you’re using inline assembly with the memory-safe annotation via a NatSpec comment, you need to switch to the proper syntax.

What’s Changing

Old (deprecated):

/// @solidity memory-safe-assembly
assembly {
    // ...
}

New (required):

assembly ("memory-safe") {    // ...}

Why It Matters

The memory-safe annotation tells the optimizer that your assembly block follows Solidity's memory model. This enables certain optimizations that would otherwise be disabled globally when any assembly is present.

The comment-based syntax was a temporary workaround for backwards compatibility. Now that the proper syntax has been available for several versions, the workaround is being removed.

How to Fix It

Find and replace all instances:

# Find all files with the old syntaxgrep -r "@solidity memory-safe-assembly" contracts

Then update each one:

Before:

function efficientHash(bytes memory data) internal pure returns (bytes32 result) {    /// @solidity memory-safe-assembly    assembly {        result := keccak256(add(data, 32), mload(data))    }}

After:

function efficientHash(bytes memory data) internal pure returns (bytes32 result) {    assembly ("memory-safe") {        result := keccak256(add(data, 32), mload(data))    }}

5. Contract Type Comparisons Deprecated

Comparing variables of different contract types is now deprecated and will be removed in 0.9.0.

What’s Changing

This will not compile in 0.9.0:

contract A {}contract B {}function compare(A a, B b) external pure returns (bool) {    return a == b; // Deprecated: comparing different contract types}

Why It’s Being Removed

Comparing two different contract types is almost always a bug. If A and B are different contracts, comparing them directly doesn't make semantic sense, you're comparing addresses, but the type system suggests you're comparing contract instances.

The Solidity team decided to make this a compile error to catch bugs early.

How to Fix It

If you actually need to compare the underlying addresses, cast explicitly:

Before:

function isSameAddress(A a, B b) external pure returns (bool) {    return a == b;}

After:

function isSameAddress(A a, B b) external pure returns (bool) {    return address(a) == address(b);}

This makes the intent clear: you’re comparing addresses, not contract instances.

6. How to Check Your Codebase

Don’t wait for 0.9.0 to find out what breaks. Use 0.8.31+ now to surface all deprecation warnings.

Step 1: Update Your Compiler

In your foundry.toml:

[profile.default]solc_version = "0.8.33"

Or in hardhat.config.js:

module.exports = {  solidity: "0.8.33",};

Step 2: Compile and Capture Warnings

# Foundryforge build 2>&1 | grep -i "deprecated"# Hardhatnpx hardhat compile 2>&1 | grep -i "deprecated"

Step 3: Address Each Warning

Step 4: Run Your Test Suite

After each change, verify nothing broke:

forge test

Step 5: Consider a Phased Rollout

If you’re maintaining deployed contracts:

  1. Fix warnings in development: update your codebase now

  2. Deploy new versions: new deployments use updated code

  3. Migrate when ready: existing deployments can migrate via upgrades (if upgradeable) or redeployment

Conclusion

Solidity 0.9.0 is cleaning up years of accumulated technical debt. The deprecations make sense, they remove footguns, simplify the language, and encourage better patterns.

The key is to start now:

  1. Upgrade to 0.8.31+ to see all deprecation warnings

  2. Fix the high-priority items (transfer/send) first

  3. Test thoroughly after each change

  4. Don’t wait for 0.9.0 to break your CI pipeline

If you’re maintaining a large codebase or want a second opinion on your migration strategy, we’re happy to help.

Ready to Build Something Great?

Partner with Chain Industries to transform your ideas into secure, scalable blockchain solutions.

Ready to Build Something Great?

Partner with Chain Industries to transform your ideas into secure, scalable blockchain solutions.

Ready to Build Something Great?

Partner with Chain Industries to transform your ideas into secure, scalable blockchain solutions.