Solidity is the language most smart contracts are written in — the code that lives at a contract account and runs on the EVM. It looks a little like JavaScript, but it runs in a very unusual environment: every line costs gas, storage is permanent and public, and a deployed contract usually can't be changed. This lesson walks through the anatomy of a Solidity contract — its structure, its types, state variables, and functions — by building up a small, real contract you could actually deploy. By the end you can read most simple contracts and write one yourself.
- Lay out a Solidity file: license, pragma, and a contract block
- Use the core value types and understand state vs local variables
- Write functions with visibility and state-mutability modifiers
- Read and reason about a small, complete contract
The skeleton of a contract
Every Solidity file starts with two lines and a contract block. The first is an SPDX licence identifier, a machine-readable note of the code's licence (the compiler warns if it's missing). The second is the pragma, which pins the compiler version so your code isn't compiled by an incompatible future version — ^0.8.20 means 0.8.20 or any compatible 0.8.x. Then comes the contract keyword and a name; everything inside the braces — the state variables and functions — is what gets deployed to a contract account. This skeleton never changes; internalise it and every contract you read starts to look familiar.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Counter {
// state variables and functions go here
uint256 public count;
function increment() public {
count += 1;
}
}That is a complete, deployable contract. It stores a number count and offers a function to increase it. Notice public on the state variable — Solidity automatically creates a getter function for it, so anyone can read count for free. This tiny contract already demonstrates the two things a contract is made of: persistent state (the number, stored on-chain forever) and functions that change it.
Types you'll use constantly
Solidity is statically typed — every variable declares its type. A handful cover most needs. uint256 is an unsigned (non-negative) integer, the workhorse for amounts, counts and balances; you'll see uint as shorthand for it. bool is true/false. address holds a 20-byte account address and has members like .balance. string holds text and bytes holds raw bytes. For collections, mapping(address => uint256) is a key-value store — think of it as a dictionary from addresses to numbers, ideal for tracking balances — and arrays hold ordered lists. Choosing the right type matters because storage costs gas and types can't silently overflow (since Solidity 0.8, arithmetic that would overflow reverts automatically).
uint256 public totalSupply; // a non-negative integer
bool public paused; // true / false
address public owner; // an account address
string public name; // text, e.g. a token name
mapping(address => uint256) public balanceOf; // address -> amountState variables vs local variables
This distinction is central to writing correct, gas-aware contracts. A state variable is declared at the contract level and lives in the contract's permanent, on-chain storage — it persists between transactions and is part of the world state, so writing to it costs significant gas. A local variable is declared inside a function and lives only for that call in cheap, temporary memory; it vanishes when the function returns. The rule of thumb: state variables are the contract's long-term memory (a balance, an owner), and local variables are scratch space for a single computation. Because storage writes are expensive, well-written contracts minimise how often they touch state.
Storage is permanent, public, and expensive; memory is temporary, private to the call, and cheap. Every state variable you add and every time you write to one costs real gas forever. Reaching for a local variable when you only need a value during one call is both cheaper and clearer.
Functions, visibility and mutability
Functions carry two important labels. Visibility says who can call them: public (anyone, and other contract code), external (only from outside the contract), internal (only this contract and ones that inherit it), and private (only this contract). State mutability says what they do to state: a plain function can change state and costs gas; a view function only reads state and is free to call from outside; a pure function uses neither state nor even reads it. Marking a read-only function view signals intent and lets others query it without paying. Getting these labels right is part of writing safe contracts — a function that should be internal but is left public is a common vulnerability.
// changes state -> costs gas when called in a transaction
function setOwner(address newOwner) public {
owner = newOwner;
}
// only reads state -> free to call from outside
function getOwner() public view returns (address) {
return owner;
}
// uses no state at all -> pure
function add(uint256 a, uint256 b) public pure returns (uint256) {
return a + b;
}Reading a whole small contract
Put the pieces together and you can read a real contract. Below is a minimal 'piggy bank': anyone can deposit ether, and a mapping records each depositor's balance. payable marks a function that can receive ether, and msg.sender and msg.value are built-in values giving the caller's address and the ether they sent — you'll meet these fully in the next lesson. Trace it: a deposit adds msg.value to the caller's stored balance; a withdrawal reduces it and sends ether back. Everything here is state, functions, types, and visibility — exactly the pieces from this lesson.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract PiggyBank {
mapping(address => uint256) public balanceOf; // state
function deposit() public payable { // can receive ether
balanceOf[msg.sender] += msg.value; // credit the sender
}
function withdraw(uint256 amount) public {
require(balanceOf[msg.sender] >= amount, "insufficient");
balanceOf[msg.sender] -= amount; // update state first
payable(msg.sender).transfer(amount); // then send ether
}
}If you can follow that contract, you have the foundation. The next lesson adds the patterns that make contracts safe and expressive — events, modifiers, require, and the meaning of msg.sender — turning these building blocks into contracts you'd actually deploy.