Writing Efficient Smart Contracts with Solidity — Practical Patterns
Storage, reentrancy, gas, and the structural decisions that separate contracts that survive from contracts that drain.
I have written my share of Solidity. The contract that taught me the most was not a DeFi product — it was a small NFT minting contract for a client, and it worked perfectly on testnet. On mainnet it cost users roughly $180 more per mint than our estimate said it should. The logic was right. The storage layout was wrong, and every single transaction paid for it.
That experience pushed me to systematize everything I now apply on every contract I ship. This is not a Solidity tutorial from scratch — I assume you know the language basics. This is the pattern guide I wish someone had handed me before that mint: how to lay out storage, why order of operations can drain your treasury, how to spend less gas per call, and the failure modes that show up only after you deploy.
Before Anything: Know What Gas Actually Buys
Every pattern below exists for one of two reasons: correctness under adversarial conditions, or raw cost per operation. You need both in your head before you write a single line.
Storage is the expensive part. Writing a fresh 32-byte slot costs 20,000 gas; updating an existing slot costs 5,000; reading a slot costs 2,100 (or 100 with warm access after the first read in a transaction). Compute is cheap in comparison — a SSTORE dwarfs almost any arithmetic. This asymmetry drives nearly every "efficiency" decision you will see.
So the mental model is: treat the contract's storage as a public database you are paying rent on every single time it changes. Every pattern below either reduces how many slots change or protects the slots that matter.
Pattern 1: Pack Your Storage
Solidity lays out state variables in 32-byte slots in declaration order, and it does not repack across slots. That means the order you declare variables changes your contract's storage footprint — sometimes dramatically.
// Expensive: 5 slots for 5 values
uint256 id; // slot 0
address owner; // slot 1
uint64 created; // slot 2
uint64 expires; // slot 3
bool active; // slot 4
// Packed: 3 slots for the same data
uint256 id; // slot 0
address owner; // slot 1 (address is 20 bytes)
uint64 created; // slot 1 tail (8 bytes) — fits in the same slot
uint64 expires; // still slot 1? No — packed with created above
bool active; // slot 2
The packed layout merges owner (20 bytes) + created (8) + expires (8) into a single 36-byte region spanning two slots, and tucks bool active into the leftover byte of the second slot. The rule of thumb: group smaller types (bool, uint8, address, small uintN) together and keep uint256 values by themselves.
A warning before you over-optimize: packing reads and writes is a one-time deployment saving per struct instance, but it can cost you on structs you read frequently, because a packed struct forces Solidity to do mask-and-shift arithmetic on every field read. For hot structs, test both layouts on a real gas profile before you commit.
Pattern 2: Checks-Effects-Interactions — the Reentrancy Rule
This is the single most important correctness pattern in Solidity, and it is older than any of the hacks you have read about. The rule is brutally simple: check conditions, update your own state, then talk to external contracts — in that order, always.
// WRONG — external call before state update
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "insufficient balance");
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
balances[msg.sender] -= amount; // state updated AFTER external call
}
// CORRECT — checks, effects, then interactions
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "insufficient balance");
balances[msg.sender] -= amount; // effect first
(bool ok, ) = msg.sender.call{value: amount}(""); // interaction last
require(ok, "transfer failed");
}
In the wrong version, the attacker's fallback function re-enters withdraw before their balance is decremented — so the balance check passes a second time, and the contract keeps paying out. In the correct version, the balance is already lowered, so the re-entrant call fails the check and the attack dies in one line.
If you take nothing else from this article, take this pattern. Roughly every reentrancy exploit on record violates exactly this ordering.
Pattern 3: Guard High-Risk Functions with ReentrancyGuard
Checks-Effects-Interactions covers the common case, but you cannot always restructure state so cleanly — some flows need multiple external calls (think a swap pipeline that talks to three pools). For those, add a mutex.
abstract contract ReentrancyGuard {
uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;
uint256 private _status = _NOT_ENTERED;
modifier nonReentrant() {
require(_status == _NOT_ENTERED, "reentrancy");
_status = _ENTERED;
_;
_status = _NOT_ENTERED;
}
}
Slap nonReentrant on every mutating function that touches external contracts. It costs a single storage write on entry and exit, and it converts an entire class of attacks into a plain require revert. Do not put it on every getter — only on state-changing, externally-touching functions, so you keep the gas cost where it earns its keep.
Pattern 4: Use Custom Errors Instead of Require Strings
Solidity 0.8.4 introduced custom errors, and they are strictly better than require(condition, "message") for gas:
error InsufficientBalance(uint256 available, uint256 requested);
error NotOwner();
error Paused();
function withdraw(uint256 amount) external {
uint256 bal = balances[msg.sender];
if (bal < amount) revert InsufficientBalance(bal, amount);
// ...
}
A string revert costs 36 bytes of calldata encoding plus the string's storage in the return data — roughly 500–600 extra gas on every failure path. A custom error with two uint256 parameters costs about half of that and gives you structured data to parse off-chain. In 2026, if you are still shipping require(msg.sender == owner, "Not owner"), your revert paths are wasting money on a codebase where failure is the common case.
There is a second, quieter win: custom errors carry data. A wallet or a backend can read InsufficientBalance(12, 50) and render a meaningful message instead of guessing from a string. Your tooling improves for free.
Pattern 5: Prefer Static-Int Data and Be Careful with Arrays
Two structural habits keep contracts cheap:
Static arrays beat dynamic ones for small, fixed collections. address[5] whitelist lives inline in its slot; address[] whitelist requires a separate storage region plus a SSTORE for the array length on every push. If you know the cap, declare it.
Precompute outside the hot loop. Anything you can compute once per transaction, compute once:
// Repeatedly recomputed on each iteration
function bad(uint256[] calldata ids) external {
for (uint256 i = 0; i < ids.length; i++) {
require(ids[i] != address(0), "zero");
}
}
// Same check, computed once — and using calldata
function good(uint256[] calldata ids) external {
uint256 n = ids.length;
for (uint256 i = 0; i < n; i++) {
if (ids[i] == 0) revert InvalidId();
}
}
Notice the second version also takes calldata, not memory. Calldata reads cost 16 gas versus 3 for the first element plus 6 per further 32 bytes for memory — and copying a large array into memory in the first place can eat thousands of gas. Read-only parameters should be calldata unless you genuinely need to modify them.
Pattern 6: Solidity 0.8+ Gave You Safe Math — Use It
Pre-0.8, integer overflow silently wrapped: uint256(0) - 1 == 2^256 - 1. That is how the old ERC-20 era produced drained balances. Since 0.8.0, arithmetic reverts on overflow by default, so the compiler does the checking for you.
What this means in practice: you no longer need SafeMath imports on 0.8+ codebases. What you do still need is to think about the places where you want a controlled wrap — unchecked blocks inside tight loops where you have already proven the bounds:
function sum(uint256[] calldata vals) external pure returns (uint256 total) {
for (uint256 i = 0; i < vals.length; i++) {
unchecked {
total += vals[i]; // safe: total can't exceed vals.length * max(uint256)…
// but only if you prove it can't wrap for YOUR data
}
}
}
Only use unchecked when you have a proof, not a hunch. An overflow you did not see coming is the one you will never find in testing.
Pattern 7: Structs, Events, and Logging Costs
Events are cheap relative to storage — an event with indexed topics costs about 375 gas per topic plus 8 per byte of data. Use them generously for anything a frontend or an indexer will need, because reading past storage is expensive and reading events is essentially free.
event Transfer(address indexed from, address indexed to, uint256 amount);
Prefer indexed on addresses and identifiers, not on uint256 amounts — the index increases cost and you can query ranges on amounts in a normal indexer only if they are not indexed. Log every meaningful state change; it doubles as your audit trail and your cheap read path.
Pattern 8: Pull Payments Over Push Payments
A contract that pushes funds (calls transfer or .call{value:} to an arbitrary user) pays for the user's gas failures and opens a reentrancy surface on every payout. A pull payment flips the responsibility: the contract records that a user is owed funds, and the user calls withdraw when they want the money.
mapping(address => uint256) public pendingWithdrawals;
function claim() external nonReentrant {
uint256 amount = pendingWithdrawals[msg.sender];
if (amount == 0) revert NothingOwed();
pendingWithdrawals[msg.sender] = 0; // effect first
(bool ok, ) = msg.sender.call{value: amount}("");
if (!ok) pendingWithdrawals[msg.sender] = amount; // refund on failure
}
Pull payments are cheaper for the protocol (no per-recipient send failures), safer (the user's fallback cannot brick your loop), and they make your contract's accounting simpler to audit — every transfer is user-initiated. The one cost is UX: users must click "claim" instead of receiving automatically, and some of them will forget.
Pattern 9: Prefer Immutables and Constants for Fixed Values
If a value never changes after deployment, do not store it in mutable storage. constant values are inlined at compile time; immutable values are set once in the constructor and stored in code, not in a state slot. Both save a SLOAD on every access, and immutables mean you never face the "can I change this after launch" governance debate for something that was never meant to change.
address public immutable owner; // set once, no slot read cost
uint256 public constant MAX_SUPPLY = 10000; // inlined everywhere
This one looks like a micro-optimization, but in a hot function it is a real 100-gas saving per access, and it removes a whole class of "who can change this" questions from your threat model.
Pattern 10: Keep Modifiers Cheap and Pure
Modifiers are inlined into the function body, so a modifier with logic runs on every entry. Keep them to require checks and nothing more:
// Cheap, pure, no storage writes
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
// Expensive and surprising — this writes storage on every call
modifier trackCalls() {
callCount[msg.sender]++;
_;
}
Anything that writes storage or calls external code inside a modifier hides cost and side effects where readers least expect them, so keep modifiers check-only and put accounting visibly in the body.
The Failure Modes Nobody Warns You About
These bit me, in order of how much they cost:
- Packed structs that read hot. I packed a struct that the UI read on every render. The mask-and-shift overhead made reads slower than the money saved on writes. Profile both ways.
- Reentrancy in the mint flow. My first NFT contract minted the token inside a loop after an external call. A user with a
receivefallback could theoretically re-enter mid-mint. Checks-Effects-Interactions fixed it with one reorder. - Assuming warm storage. I designed for cold reads everywhere and paid warm-access surprises. If a contract calls itself or uses
nonReentrant, the second touch of a slot is cheaper — design for it. - Events as an afterthought. No indexer data, and a frontend that had to call
balanceOfper token instead of reading events. Total extra infrastructure cost, zero gas benefit. - Pushing funds instead of letting users pull. Every failed send in a payout loop reverted the whole transaction and burned the caller's gas. Moving to pull payments removed the failure mode entirely.
How I Actually Benchmark a Contract
Two habits, both free, that catch 90% of the mistakes above before they reach a testnet deployment.
First, write the gas test as a first-class test, not an afterthought. With Foundry, assert on gas ceilings per function and fail the suite when a refactor pushes a hot path over budget:
// Foundry gas test
function test_withdraw_under_gas_budget() public {
uint256 before = gasleft();
vault.withdraw(100);
uint256 used = before - gasleft();
assertLt(used, 120_000, "withdraw too expensive");
}
A gas budget test converts a silent cost regression into a red build. It is the difference between learning about the $180 mint overcharge in your own test suite and learning about it from a user's transaction receipt.
Second, forge snapshot your storage layout and diff it across refactors. Foundry's forge inspect shows you exactly how many slots your structs consume; if a "cleanup" PR quietly pushes a hot struct from 3 slots to 5, the snapshot diff catches it in review instead of in production.
The Deployment Checklist
Run this before you send any contract to mainnet:
- Storage packed (small types grouped,
uint256isolated) - Every mutating function touching external contracts marked
nonReentrant - All external calls happen after state changes (Checks-Effects-Interactions)
- Custom errors, not require strings
- Read-only function parameters are
calldata - Loop bounds hoisted out of iterations;
uncheckedonly with proof - Events emitted for every meaningful state change,
indexedon addresses - Gas profiled on the exact storage layout you will deploy, not a draft
- A read path exists through events or getters that does not rely on past state scans
- A separate script that replays your contract against the top five historical reentrancy exploits
That last checklist item is not paranoia. I run it because the one exploit I never want to read about is my own.
Efficiency in Solidity is a three-way trade between gas cost, correctness, and code readability. The patterns above are the ones that survive all three — and the ones that minted that NFT contract did not, which is exactly why the gas bill came in $180 over estimate. Cheap contracts are not clever contracts. They are contracts that respect the cost of every slot and the order of every call.
*Gulshan Yad
Using Advanced Solidity Features
Solidity has several advanced features that can help you write more efficient and effective smart contracts. One such feature is the use of abstract contracts.
Abstract contracts are contracts that cannot be instantiated directly and must be inherited by another contract. This allows you to define a contract that provides a set of functions and variables that can be used by other contracts.
For example, you can create an abstract contract that provides a set of functions for interacting with an external API. Other contracts can then inherit from this abstract contract and use the functions to interact with the API.
Using abstract contracts can help you write more modular and reusable code, making it easier to maintain and update your smart contracts.
Working with External Libraries
Solidity allows you to use external libraries in your contracts. This can be useful when you need to use a library that is not included in the Solidity standard library.
To use an external library, you need to import it into your contract using the import statement. You can then use the functions and variables provided by the library in your contract.
For example, you can import the SafeMath library to use its functions for safe arithmetic operations.
Using external libraries can help you write more efficient and effective smart contracts, but it also introduces some security risks. Make sure to use libraries from trusted sources and follow secure coding practices to minimize the risks.
Optimizing Gas Consumption
Gas consumption is a critical factor in the efficiency of your smart contract. Gas is the unit of measurement for the computational resources required to execute a transaction on the blockchain.
To optimize gas consumption, you need to minimize the number of function calls and reduce data storage. You can do this by using more efficient data types, reducing the number of variables, and using caching to store frequently accessed data.
For example, you can use the uint256 type instead of uint128 to store a 256-bit unsigned integer. You can also use caching to store the result of a complex calculation so that it can be reused instead of recalculated.
Using Inheritance and Polymorphism
Inheritance and polymorphism are two powerful features in Solidity that can help you write more modular and reusable code.
Inheritance allows you to create a new contract that inherits the functions and variables of an existing contract. This can be useful when you need to create a contract that builds upon the functionality of another contract.
Polymorphism allows you to use a single function to perform different actions based on the type of data being passed to it. This can be useful when you need to write a function that can handle different types of data.
For example, you can create a contract that inherits from another contract and adds new functions to it. You can also create a function that takes a parameter of type address and performs different actions based on the type of address being passed to it.
Using inheritance and polymorphism can help you write more efficient and effective smart contracts, but it also introduces some complexity. Make sure to use these features judiciously and follow best practices to minimize the risks.
Testing Smart Contracts
Testing is a critical step in ensuring the correctness and security of your smart contract. Testing involves writing code that simulates the behavior of your contract and checks that it behaves as expected.
To test your smart contract, you can use tools like Truffle and Ethers.js. These tools provide a set of functions that allow you to create a test environment and run your contract in it.
For example, you can use Truffle to create a test environment and run your contract in it. You can then use Ethers.js to simulate the behavior of your contract and check that it behaves as expected.
Best Practices for Smart Contract Development
Smart contract development is a complex process that requires careful planning and execution. To ensure the success of your project, you need to follow best practices for smart contract development.
Some of the best practices for smart contract development include:
- Using secure coding practices
- Keeping your contract up-to-date with the latest Solidity version
- Testing your contract thoroughly
- Using gas-efficient deployment strategies
- Following best practices for modular and reusable code
By following these best practices, you can ensure the success of your smart contract project and minimize the risks associated with it.
Key Takeaways
- Use libraries like SafeMath to avoid arithmetic errors and improve code readability.
- Implement event-driven programming to decouple smart contract logic from external dependencies.
- Optimize gas consumption by minimizing function calls and reducing data storage.
- Use inheritance and polymorphism to create reusable and modular smart contract code.
- Test smart contracts thoroughly using tools like Truffle and Ethers.js.
- Keep smart contracts up-to-date with the latest Solidity version for security and performance improvements.
Frequently Asked Questions
What is the main difference between a library and a contract in Solidity?
In Solidity, a library is a reusable piece of code that can be imported and used within a contract, whereas a contract is a standalone piece of code that can be deployed on the blockchain.
How can I reduce gas costs in my smart contract?
To minimize gas costs, reduce the number of function calls, minimize data storage, and use more efficient data types.
Can I use external libraries in my Solidity contract?
Yes, you can use external libraries in your Solidity contract by importing them using the import statement.
How can I ensure the security of my smart contract?
To ensure the security of your smart contract, use secure coding practices, keep your contract up-to-date with the latest Solidity version, and test your contract thoroughly.
What is the difference between a function and a modifier in Solidity?
In Solidity, a function is a block of code that performs an action, whereas a modifier is a function that can be applied to another function to add additional behavior.
Can I use Solidity to interact with other blockchain platforms?
While Solidity is primarily used for Ethereum, some libraries and tools allow you to interact with other blockchain platforms.
How can I optimize the deployment of my smart contract?
To optimize the deployment of your smart contract, use tools like Truffle and Ethers.js, and consider using a gas-efficient deployment strategy.
1 followers
AI systems builder · 7 years in production. RAG, self-hosted infra, agent architecture. 📬 Deep-dives → mrgulshanyadav.substack.com





Comments
Sign in to join the conversation
No comments yet. Be the first to share your thoughts!