Sending Ether in Solidity: transfer, send, and call
Solidity gives you three ways to send Ether from a contract: transfer, send, and call. They look similar on the surface, but they behave very differently when it comes to gas, error handling, and security. Choosing the wrong one can break your contract or open it to attacks.
This post covers how each method works, where they differ, and which one to use today.
The Three Methods
transfer
transfer forwards a fixed 2300 gas to the recipient and automatically reverts the entire transaction if the transfer fails. No return value. No manual error checking required.
function sendViaTransfer(address payable _to) public payable {
_to.transfer(msg.value);
}
The 2300 gas stipend is intentionally small. It is enough for the recipient to emit an event, but not enough to make external calls or run complex logic. This was designed as a security feature to block reentrancy attacks.
After the Istanbul hard fork (EIP-1884, 2019), the gas cost of
SLOADincreased from 200 to 800. This means some recipient contracts that used to work fine with 2300 gas now run out of gas and causetransferto fail. Contracts you did not write might break without any code change on your end.
send
send also forwards 2300 gas, but instead of reverting on failure, it returns a bool. You are responsible for checking it.
function sendViaSend(address payable _to) public payable {
bool sent = _to.send(msg.value);
require(sent, "Failed to send Ether");
}
If you forget to check the return value, your contract continues executing as if the transfer succeeded, even when it did not. This is a real bug pattern. The Solidity compiler will warn you, but it will not stop you.
send has the same gas-related problems as transfer. There is almost no reason to use it over transfer (if you want automatic revert) or call (if you want flexibility).
call
call is a low-level function. It forwards all remaining gas by default and returns two values: a success boolean and any returned bytes.
function sendViaCall(address payable _to) public payable {
(bool sent, bytes memory data) = _to.call{value: msg.value}("");
require(sent, "Failed to send Ether");
}
The {value: msg.value} syntax sets how much Ether to send. The empty string "" means you are not calling a specific function on the recipient, just sending Ether.
You can also specify a custom gas limit:
(bool sent, ) = _to.call{value: msg.value, gas: 10000}("");
Because call forwards all gas, the recipient can run complex logic. This makes it flexible, but it also means reentrancy attacks become possible if you are not careful.
Comparison at a Glance
| Method | Gas forwarded | On failure | Manual check needed |
|---|---|---|---|
transfer | 2300 (fixed) | Reverts automatically | No |
send | 2300 (fixed) | Returns false | Yes |
call | All remaining (configurable) | Returns false | Yes |
Which Method to Use
Use call.
After EIP-1884, the 2300 gas limit is no longer a reliable safety net. transfer and send can silently break when interacting with contracts that have non-trivial fallback logic. call avoids this by not capping gas, and it gives you full control over error handling.
The tradeoff is that call requires you to handle reentrancy yourself. The standard approach is the checks-effects-interactions pattern: update all state before making any external call.
contract SafeSender {
mapping(address => uint) public balances;
function withdraw() public {
uint amount = balances[msg.sender];
// Check
require(amount > 0, "Nothing to withdraw");
// Effect: update state before external call
balances[msg.sender] = 0;
// Interaction: send Ether last
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
The balance is set to zero before calling call. Even if the recipient re-enters withdraw, their balance is already zero, so they get nothing on the second call.
If your withdrawal logic is more complex, consider a reentrancy guard instead of relying solely on ordering:
contract SafeSenderWithGuard {
mapping(address => uint) public balances;
bool private locked;
modifier noReentrant() {
require(!locked, "No reentrancy");
locked = true;
_;
locked = false;
}
function withdraw() public noReentrant {
uint amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
Receiving Ether in a Contract
For a contract to receive Ether sent via call, it needs at least one of these functions:
contract EtherReceiver {
event Received(address sender, uint amount);
// Called when msg.data is empty (plain Ether transfer)
receive() external payable {
emit Received(msg.sender, msg.value);
}
// Called when no other function matches
fallback() external payable {}
}
receive handles plain Ether transfers with no calldata. fallback catches everything else. If neither exists, the contract will reject incoming Ether and cause your call to fail.
Full Working Example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract EtherSender {
function sendViaTransfer(address payable _to) public payable {
_to.transfer(msg.value);
}
function sendViaSend(address payable _to) public payable {
bool sent = _to.send(msg.value);
require(sent, "Send failed");
}
function sendViaCall(address payable _to) public payable {
(bool sent, ) = _to.call{value: msg.value}("");
require(sent, "Call failed");
}
}
contract EtherReceiver {
event Received(address sender, uint amount);
receive() external payable {
emit Received(msg.sender, msg.value);
}
}
When you call sendViaCall with 1 Ether and pass an EtherReceiver address, the Ether is forwarded and the Received event is emitted. The other two methods will also work with this receiver, but they carry the gas-limit risk described above.
Conclusion
Use call for sending Ether in any contract you write today. Always check the boolean return value. Always update contract state before the external call.
transfer and send exist in older contracts and tutorials, so you will see them. But the gas assumptions they rely on are no longer safe after Istanbul. call with the checks-effects-interactions pattern (or a reentrancy guard) is the right approach.