Visibility in Solidity
Solidity gives you four visibility modifiers: public, private, internal, and external. They control which code can call a function or read a state variable. Getting them wrong either locks down functionality that should be open, or exposes internals that should stay hidden.
This post explains what each modifier does, where it applies, how it interacts with inheritance, and when to reach for each one.
The Four Modifiers at a Glance
| Modifier | State variables | Functions | Who can access |
|---|---|---|---|
public | Yes | Yes | Anyone, anywhere |
private | Yes | Yes | Current contract only |
internal | Yes (default) | Yes | Current contract and derived contracts |
external | No | Yes | Outside callers only |
external cannot be used on state variables. Attempting it will cause a compile error.
State Variable Visibility
public
Declaring a state variable public tells the compiler to generate a getter function automatically. External callers use that getter to read the value. You do not write it yourself.
contract VisibilityExample {
uint256 public myNumber = 42;
// Compiler generates: function myNumber() public view returns (uint256)
}
Any external contract or wallet can call myNumber() and get back 42. This is clean and convenient, but only declare variables public if you actually want them readable by anyone.
private
A private variable can only be accessed by functions inside the same contract. Derived contracts cannot read it, even though they inherit from the same base.
contract MyContract {
uint256 private secretValue = 100;
function revealSecret() public view returns (uint256) {
return secretValue; // allowed
}
}
contract ChildContract is MyContract {
function tryAccess() public view returns (uint256) {
// return secretValue; // compile error
return 0;
}
}
privatedoes not hide data from the blockchain. All storage slots are publicly readable by anyone who queries the chain directly.privateonly prevents other Solidity contracts from referencing the variable by name. Never store sensitive data on-chain and assumeprivateprotects it.
internal
internal is the default visibility for state variables. If you declare a variable without specifying visibility, Solidity treats it as internal. The variable is accessible within the contract and within any contract that inherits from it.
contract Parent {
uint256 internal familySecret = 500;
}
contract Child is Parent {
function read() public view returns (uint256) {
return familySecret; // allowed
}
}
Prefer writing internal explicitly rather than relying on the default. It makes intent clear.
Function Visibility
public
A public function can be called from inside the contract or from outside. It is the most flexible option, but it has a cost: when called internally, Solidity copies function arguments into memory. For small inputs this does not matter, but for large arrays it adds unnecessary gas overhead.
contract Calculator {
function add(uint256 a, uint256 b) public pure returns (uint256) {
return a + b;
}
function calculate() public pure returns (uint256) {
return add(10, 20); // internal call to public function
}
}external
An external function can only be called from outside the contract. You cannot call it directly by name from within the same contract. Calling it internally requires this.functionName(), which creates an external call and costs significantly more gas.
contract DataProcessor {
function processData(uint256[] calldata data) external pure returns (uint256) {
return data.length;
}
function internalProcess() public pure returns (uint256) {
uint256[] memory testData = new uint256[](3);
// return processData(testData); // compile error
return this.processData(testData); // works, but expensive
}
}
The main advantage of external is that it allows calldata for array parameters. calldata is read directly from the transaction input without copying, which is cheaper than loading into memory. If a function only ever gets called from outside and accepts large arrays, external with calldata is the correct choice.
internal
An internal function is accessible within the contract and in any contract that inherits from it. It is never part of the contract's ABI, so no external caller can invoke it.
contract Math {
function multiply(uint256 a, uint256 b) internal pure returns (uint256) {
return a * b;
}
function square(uint256 x) public pure returns (uint256) {
return multiply(x, x);
}
}
contract AdvancedMath is Math {
function cube(uint256 x) public pure returns (uint256) {
return multiply(x, multiply(x, x)); // inherited, accessible
}
}
Use internal for helper functions that child contracts may need to reuse. It avoids duplicating logic while keeping the function off the public interface.
private
A private function works the same as internal within the current contract, but derived contracts cannot call it at all.
contract Secure {
uint256 private counter;
function incrementCounter() private {
counter++;
}
function doSomething() public {
incrementCounter(); // allowed
}
}
contract Extended is Secure {
function tryIncrement() public {
// incrementCounter(); // compile error
}
}
Use private when a function is a low-level implementation detail that should never be part of an inheritance interface.
Visibility Across Inheritance
This example shows all four modifiers in one inheritance scenario:
contract Base {
uint256 public publicVar = 1;
uint256 internal internalVar = 2;
uint256 private privateVar = 3;
function publicFunc() public pure returns (string memory) {
return "public";
}
function internalFunc() internal pure returns (string memory) {
return "internal";
}
function privateFunc() private pure returns (string memory) {
return "private";
}
}
contract Derived is Base {
function testAccess() public view returns (uint256) {
uint256 sum = publicVar + internalVar; // both accessible
// uint256 bad = privateVar; // compile error
publicFunc(); // accessible
internalFunc(); // accessible
// privateFunc(); // compile error
return sum;
}
}
Derived contracts inherit public and internal members. They never get access to private members. This is a hard boundary enforced at compile time.
Practical Guidelines
Default to the most restrictive option. Start with private. Move to internal if child contracts need access. Move to public or external only if outside callers need it.
Use external for functions that only serve outside callers. Especially if those functions accept array parameters. The calldata keyword saves gas and external makes the intent explicit.
Use internal for shared helpers in a contract family. It is cleaner than duplicating logic or making everything public.
Do not treat private as encryption. All on-chain state is readable. Use private for access control at the Solidity level, not for data confidentiality.
Let the compiler generate getters. Public state variables get their getter for free. Only write a custom getter if you need to transform or filter the output.
Common Mistakes
contract Mistakes {
// Mistake 1: public when internal would work
// Nothing outside this contract reads helperValue
uint256 public helperValue;
// Mistake 2: public when external would work
// This function is never called internally
// Switching to external with calldata saves gas
function processArray(uint256[] memory data) public pure returns (uint256) {
return data.length;
}
// Mistake 3: missing visibility on a function
// Default for functions is internal, not public
// This function is not callable externally
function oops() pure returns (uint256) {
return 42;
}
}
The third mistake is subtle. State variables default to internal, but functions also default to internal if you omit the modifier. A function without visibility is invisible to external callers, which may not be what you intended. Always write visibility explicitly on functions.
Visibility and Attack Surface
Every public or external function is an entry point into your contract. Unnecessary exposure increases the number of ways an attacker can interact with your code.
contract SecureExample {
mapping(address => uint256) private balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// Private: only used internally, not part of the ABI
function updateBalance(address user, uint256 amount) private {
balances[user] = amount;
}
// External: read-only, only exposes the caller's own balance
function getBalance() external view returns (uint256) {
return balances[msg.sender];
}
}
The balances mapping is private. The updateBalance helper is private. The only entry points are deposit and getBalance. This is the smallest possible attack surface for this functionality.
Keeping functions private or internal until you have a reason to expose them is a habit that pays off in audit findings and in production.
Visibility modifiers are straightforward once you understand what each one controls. The rule is simple: expose only what needs to be exposed, and be explicit about every choice. A contract where every variable and function has a deliberate visibility modifier is easier to audit, easier to extend, and harder to exploit.