Common Solidity Mistakes That Waste Gas and Compromise Security
The most frequent Solidity mistakes that inflate gas costs and create security vulnerabilities — with practical fixes for each pattern developers get wrong.
Solidity gives you enough rope to hang yourself. The language compiles almost anything, and subtle mistakes do not generate compiler errors — they generate expensive transactions or exploitable contracts. Most of the patterns below are common in code written by developers who know Solidity but have not yet internalized the EVM's cost model or its security constraints.
Common Solidity Mistakes That Waste Gas
Reading State Variables Multiple Times in a Loop
Storage reads cost 100–2100 gas depending on whether the slot is warm or cold. Reading the same state variable inside a loop multiplies that cost:
// Expensive — reads storage on every iteration
for (uint256 i = 0; i < totalItems; i++) {
process(items[i]);
}
Cache state variables in local memory variables before the loop:
uint256 length = totalItems; // one storage read
for (uint256 i = 0; i < length; i++) {
process(items[i]);
}
This is the single highest-impact gas optimization for most contracts with loops.
Using uint8 and uint16 to "Save Space"
Developers assume smaller integer types save gas. They save storage space when packed correctly, but they cost more gas in computation because the EVM operates on 256-bit words and must mask smaller values:
// More expensive to compute than uint256
uint8 counter;
counter++;
Use uint256 for standalone variables that are not packed with other values in storage. Only use smaller types when packing multiple values into a single storage slot.
Storing Everything On-Chain
State that does not need to be read by other contracts should not be in contract storage. Each new storage write (SSTORE to a cold slot) costs 20,000 gas. Emitting an event costs roughly 375 gas plus per-byte costs.
If your contract only needs to record that something happened for off-chain consumption, emit an event instead of writing to storage. Storing metadata, descriptions, or images entirely on-chain is almost always the wrong approach.
Using string for Fixed Values
Dynamic-length string and bytes types involve memory allocation. If your value fits in 32 bytes, use bytes32 instead:
// Wasteful
string public version = "1.0";
// Better
bytes32 public constant VERSION = "1.0";
Constants do not occupy storage at all — they are inlined into bytecode.
Redundant Visibility on Overridden Functions
A function marked both public and virtual that never needs external calls could be internal. Every public function adds an entry point to the contract's dispatch table. Declaring functions as external (when they are never called internally) is cheaper because external parameters use calldata rather than requiring a copy to memory.
Common Solidity Mistakes That Compromise Security
Authorization with tx.origin
tx.origin returns the address of the original transaction signer, not the immediate caller. A malicious contract can call your function on behalf of a trusted user, and your tx.origin check will pass:
// Vulnerable
require(tx.origin == owner, "Not authorized");
// Correct
require(msg.sender == owner, "Not authorized");
Never use tx.origin for access control.
Unchecked Return Values from External Calls
.call() returns a bool indicating success. Ignoring it means your contract silently continues after a failed transfer:
// Silent failure
target.call{value: amount}("");
// Correct
(bool success, ) = target.call{value: amount}("");
require(success, "Call failed");
Low-level calls do not propagate reverts. You must check the return value manually.
Arithmetic Without Bounds Checking
Solidity 0.8.x has built-in overflow and underflow protection — but only for unchecked arithmetic outside an unchecked {} block. If you use unchecked {} for gas savings, you must manually verify that the arithmetic cannot overflow or underflow in that context:
unchecked {
// Only safe if you have proven a - b cannot underflow
uint256 result = a - b;
}
Do not use unchecked globally across a function. Scope it tightly to operations you have verified.
Delegatecall to User-Supplied Addresses
delegatecall executes code from another contract in the context of your own contract's storage. Calling a user-supplied address with delegatecall lets an attacker run arbitrary code with full access to your storage:
// Never do this
address impl = userSuppliedAddress;
impl.delegatecall(data);
Only delegatecall to addresses you control and have audited.
Missing Input Validation on Array Parameters
Functions that accept arrays and iterate over them without length bounds can run out of gas. Functions that accept two paired arrays without checking that their lengths match will silently misalign:
function batchTransfer(address[] calldata recipients, uint256[] calldata amounts) external {
// Always validate this
require(recipients.length == amounts.length, "Length mismatch");
require(recipients.length <= 200, "Too many recipients");
// ...
}
Floating Pragma
pragma solidity ^0.8.0; allows compilation with any 0.8.x version. Different patch versions have different compiler behavior, optimizer behavior, and known bugs. Pin to an exact version:
pragma solidity 0.8.28;
This is a one-line change that eliminates an entire class of environment-specific issues.
A Practical Review Habit
Before submitting a pull request with any contract changes, walk through this mental checklist:
- Does any loop read a state variable more than once?
- Does any function call into an external address without a reentrancy guard?
- Does any function accept an address parameter without validating it?
- Is
tx.originused anywhere? - Are all
.call()return values checked?
These take less than five minutes to check and catch the majority of high-severity issues.
If you want a contract development team that catches these patterns before code review rather than after, Clixo builds Solidity systems where these checks are part of the engineering process from the start.