Why these concepts matter

In production Solidity code, visibility and data location are not style choices. They define:

  • Security boundaries: which functions can be called from outside the contract.
  • Gas usage: how much copying and decoding the EVM must perform.
  • API clarity: whether a function is meant for users, derived contracts, or internal reuse.
  • Mutation semantics: whether a function works on persistent state or temporary data.

A contract with sloppy visibility can expose internal helpers as public entry points. A contract with careless data location can waste gas by copying large arrays or accidentally mutate storage when a temporary in-memory value was intended.


Function visibility in Solidity

Solidity supports four visibility specifiers for functions:

VisibilityCallable fromTypical use
publicInside and outside the contractExternal API plus internal reuse
externalOnly from outside the contractUser-facing entry points, especially for large calldata
internalInside the contract and derived contractsShared implementation logic
privateOnly inside the defining contractEncapsulated helpers

public vs external

A public function can be called internally like a normal function and externally through the ABI. An external function can only be called from outside the contract, unless invoked with this.functionName(...), which performs an external call and is usually inefficient.

Use public when the function is part of the contract’s external API and you need internal reuse. Use external when the function is only an entry point and does not need internal calls.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Vault {
    mapping(address => uint256) private balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function balanceOf(address account) external view returns (uint256) {
        return balances[account];
    }

    function withdraw(uint256 amount) external {
        _withdraw(msg.sender, amount);
    }

    function emergencyWithdraw(address account, uint256 amount) public {
        // Example of a public function that can be reused internally or externally.
        _withdraw(account, amount);
    }

    function _withdraw(address account, uint256 amount) internal {
        require(balances[account] >= amount, "insufficient balance");
        balances[account] -= amount;
        payable(account).transfer(amount);
    }
}

In this example, _withdraw is internal because it is implementation logic shared by multiple entry points. withdraw and balanceOf are external because they are intended for users. emergencyWithdraw is public only because the contract wants to expose the same logic both externally and internally.

internal and private

internal functions are visible to the contract and any contract that inherits from it. This makes them ideal for reusable business logic in base contracts.

private functions are visible only in the contract where they are defined. They are useful for helpers that should never become part of a derived contract’s design surface.

A practical rule:

  • Use internal for extension points and shared logic.
  • Use private for implementation details that should not leak into inheritance hierarchies.

Choosing the right visibility

Visibility should reflect intent, not convenience. A good contract usually follows these patterns:

  • External user actions: external
  • Read-only query functions: often external view
  • Shared logic: internal
  • Small helper routines: private

Best practices

  1. Default to the narrowest visibility that works
  • If a function does not need to be called internally, make it external.
  • If a helper is only used in one contract, make it private.
  1. Avoid exposing unnecessary public functions
  • Every public function becomes part of the contract’s ABI and audit surface.
  1. Use internal functions to reduce duplication
  • Shared validation and state transitions belong in internal helpers.
  1. Be careful with inheritance
  • internal functions can be overridden if marked virtual.
  • private functions cannot be overridden, which can simplify reasoning.

Data location: storage, memory, and calldata

Solidity uses three primary data locations for reference types such as arrays, structs, and strings.

LocationPersistenceMutabilityTypical use
storagePersistent on-chain stateMutableContract state variables
memoryTemporary during executionMutableWorking copies, return values
calldataTemporary, read-only input dataImmutableExternal function parameters

storage

storage refers to the contract’s permanent state. Writing to storage changes blockchain state and costs gas. Reading from storage is also more expensive than reading from memory or calldata.

uint256[] private storedValues;

When you assign a storage reference to another storage reference, you are not copying data; you are creating another reference to the same state.

memory

memory is temporary and disappears after the call ends. It is useful for building intermediate arrays or structs before returning them or passing them to internal functions.

function buildArray() external pure returns (uint256[] memory) {
    uint256[] memory values = new uint256[](3);
    values[0] = 10;
    values[1] = 20;
    values[2] = 30;
    return values;
}

calldata

calldata is read-only input data supplied to external functions. It is the cheapest location for large input parameters because Solidity can read from the call data directly without copying into memory.

function batchTransfer(address[] calldata recipients, uint256[] calldata amounts) external {
    require(recipients.length == amounts.length, "length mismatch");

    for (uint256 i = 0; i < recipients.length; i++) {
        // process each transfer
    }
}

For large arrays and strings, calldata is usually the right choice for external functions.


When to use each data location

A useful decision guide:

SituationRecommended locationWhy
External function receives a large arraycalldataAvoids copying
Function needs to modify a temporary arraymemorySafe, ephemeral workspace
Function updates contract statestoragePersistent mutation
Internal helper reads input onlycalldata or memoryDepends on caller and mutability
Returning dynamic datamemoryReturn values must be in memory

Example: avoiding unnecessary copies

Suppose you are validating a list of addresses in an allowlist update function. If the function is external and only reads the list, use calldata:

function setAllowlist(address[] calldata users) external onlyOwner {
    for (uint256 i = 0; i < users.length; i++) {
        allowlisted[users[i]] = true;
    }
}

If you changed the parameter to memory, Solidity would copy the entire array into memory first, increasing gas cost.


Reference types and assignment semantics

The most common source of bugs is assuming that assignment always copies data. That is not true for reference types.

Storage-to-storage assignment

When you assign one storage reference to another, both refer to the same underlying data.

struct Config {
    uint256 limit;
    bool enabled;
}

Config private config;

function updateConfig() external {
    Config storage ref = config;
    ref.limit = 100;
    ref.enabled = true;
}

Here, ref is just another name for config.

Storage-to-memory copy

Assigning storage data to a memory variable creates a copy.

function readConfig() external view returns (uint256 limit, bool enabled) {
    Config memory snapshot = config;
    return (snapshot.limit, snapshot.enabled);
}

This is useful when you want a stable snapshot for computation or return values.

Calldata is read-only

You cannot modify calldata directly. If you need to transform input data, copy it into memory first.

function normalize(address[] calldata users) external pure returns (address[] memory) {
    address[] memory copy = new address[](users.length);
    for (uint256 i = 0; i < users.length; i++) {
        copy[i] = users[i];
    }
    return copy;
}

Combining visibility and data location effectively

The strongest designs align visibility with data location:

  • external functions often accept calldata
  • internal helpers often accept memory if they need to transform data
  • private helpers often work on storage when they are mutating state

Common pattern: external entry point, internal worker

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Registry {
    mapping(address => bool) public approved;

    function approveBatch(address[] calldata users) external {
        _approveBatch(users);
    }

    function _approveBatch(address[] calldata users) internal {
        for (uint256 i = 0; i < users.length; i++) {
            approved[users[i]] = true;
        }
    }
}

This pattern keeps the external interface minimal while allowing internal reuse. Because _approveBatch only reads the list, calldata remains appropriate even in the internal function.

When internal functions need memory

If an internal function must sort, filter, or otherwise mutate input data, use memory:

function _deduplicate(address[] memory users) internal pure returns (address[] memory) {
    // Placeholder for transformation logic
    return users;
}

An external function can accept calldata, copy it once into memory, and then pass it to the internal transformer.


Practical pitfalls

1. Using public when external is enough

A public function can be slightly more expensive for large reference types because Solidity may generate code that supports both internal and external calling conventions. If the function is only an entry point, prefer external.

2. Copying large arrays into memory unnecessarily

If an external function only reads input arrays or strings, use calldata. This is especially important for batch operations and signature verification workflows.

3. Accidentally mutating storage through a reference

When you assign a storage struct or array to another storage variable, remember that both point to the same state. If you intended a copy, explicitly move to memory first.

4. Exposing helpers as part of the ABI

A public helper becomes callable by anyone. If it is not part of the intended contract interface, make it internal or private.


A concise design checklist

Before finalizing a function, ask:

  1. Who should call this function?
  • Users: external
  • Derived contracts: internal
  • Only this contract: private
  1. Does it need to read or modify temporary data?
  • Read-only external input: calldata
  • Mutable temporary data: memory
  • Persistent state: storage
  1. Will it be reused internally?
  • Yes: consider public or internal
  • No: prefer external or private
  1. Is copying avoidable?
  • If yes, use the cheapest safe location.

Summary

Function visibility and data location are foundational Solidity concepts that shape contract architecture. Visibility controls access and composability; data location controls how values are stored, copied, and mutated. Together, they influence security, gas efficiency, and code maintainability.

A strong default approach is simple:

  • Use external for user-facing entry points.
  • Use internal for shared logic.
  • Use private for implementation details.
  • Use calldata for read-only external inputs.
  • Use memory for temporary mutable data.
  • Use storage only when you truly need persistent state.

Designing with these rules in mind leads to contracts that are easier to audit, cheaper to use, and less likely to contain subtle reference bugs.

Learn more with useful resources