Skip to content

Solidity: A High-Level Language for the EVM

The previous page, What a Contract Actually Is, stripped the mysticism away: a contract is just some bytecode sitting in an account’s code field, run by the EVM against that account’s storage. But nobody writes contracts by hand-assembling opcodes like PUSH, SLOAD, and SSTORE. That is like writing a web server in raw x86. We need a language — something human-readable that a compiler turns into exactly those bytes.

That language is Solidity, and this page is about the round trip: you write source that reads like a small class, and a compiler emits the runtime bytecode that lands in the account’s code field — the same field the last page taught you to read. Along the way we name the four concepts that separate a contract from an ordinary program: state variables that persist, functions that read and write them, visibility and mutability that decide who may call what and at what cost, and a constructor that runs once and then vanishes. Keeping the throughline in view — how do untrusting strangers agree on the state of a shared world computer, and what does it cost to run one? — every one of these choices is really a choice about cost and safety on a machine anyone can call.

The EVM understands only bytecode: a flat sequence of one-byte opcodes and their inline arguments. It is a perfectly good target and a miserable source. Writing a loan protocol as raw opcodes means tracking a stack by hand, computing jump destinations yourself, and laying out storage slots in your head. One miscounted PUSH and the whole contract is silently wrong — and, because deployed code is immutable, wrong forever.

A high-level language solves this the way every compiler does: you write your intent in named variables and functions, and a compiler mechanically translates it into correct bytecode. Solidity is by far the dominant such language — the overwhelming majority of Ethereum contracts in production are written in it. Its syntax is deliberately familiar, borrowing braces and semicolons from JavaScript, C++, and Java, so that application developers can pick it up quickly.

Solidity is not the only option. Vyper is a stricter, Python-flavored alternative that deliberately removes features — no inheritance, no inline assembly by default, no unbounded loops — on the theory that a language with fewer sharp edges produces fewer exploitable contracts. Both compile down to the same EVM bytecode; the EVM neither knows nor cares which language you used. We teach Solidity here because it is what you will overwhelmingly meet in the wild, but the mental model — source compiles to the bytes in the code field — is identical for either.

Solidity source (.sol) Vyper source (.vy)
│ │
│ solc │ vyper
▼ ▼
┌─────────────────────────────────────────────┐
│ EVM bytecode (opcodes: PUSH, SSTORE, …) │
└─────────────────────────────────────────────┘
│ deploy transaction
account.code = <runtime bytecode> ← the field from the last page

Here is about the smallest contract that does something worth remembering — a single number you can read and change. It is the Solidity twin of the byte-level counter from the companion ethmini crate, whose entire job was to keep a value in storage slot 0.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Counter {
// A state variable: this lives in the contract's storage, on-chain,
// and survives between calls. It occupies storage slot 0.
uint256 public count;
// Runs once, at deploy time. Not part of the deployed runtime code.
constructor(uint256 start) {
count = start;
}
// A state-changing function: it writes storage, so it costs gas
// and can only be executed inside a transaction.
function increment() public {
count += 1;
}
// A read-only function: it promises not to modify state.
function get() public view returns (uint256) {
return count;
}
}

Read top to bottom, this maps cleanly onto machinery you have already met:

  • The contract Counter { … } block is the unit that becomes one account with its own code and storage.
  • uint256 public count is a state variable. It is stored in slot 0 of the account’s storage, exactly like the ethmini counter’s slot 0. Because it is public, the compiler also generates a free getter function named count().
  • increment() compiles to roughly the opcode sequence you saw at the byte level: load slot 0 (SLOAD), add one, store it back (SSTORE), stop.
  • get() reads slot 0 and returns it without writing anything.

The pragma line pins the compiler version, and the SPDX-License-Identifier comment is a licensing convention the compiler nags you for. Neither ends up in the bytecode; both are hygiene.

State variables vs. local variables: where does a value live?

Section titled “State variables vs. local variables: where does a value live?”

This is the single most important distinction in the language, because it is a distinction about where bytes physically live — and therefore about persistence and cost.

  • A state variable (declared at contract level, like count) lives in the account’s storage. Storage is the persistent key–value map that is part of the world state. A value written there survives after the transaction ends, is visible to the next call, and contributes to the account’s storage root. It is also, by a wide margin, the most expensive place to put a byte (more on that under the hood).
  • A local variable (declared inside a function) lives in memory or on the stack — scratch space that exists only for the duration of a single call. When the call returns, it is gone. Nothing about it is persisted; nothing about it changes the world state.
contract Example {
uint256 total; // STATE: persists in storage, on-chain
function addBatch(uint256[] calldata xs) public {
uint256 sum = 0; // LOCAL: lives only during this call
for (uint256 i = 0; i < xs.length; i++) {
sum += xs[i]; // cheap: just stack/memory arithmetic
}
total += sum; // the ONE expensive write: touches storage
}
}

The loop above adds numbers in a local sum — dozens of cheap stack operations — and touches storage exactly once at the end. Writing total += xs[i] inside the loop instead would have performed one storage write per element, which on Ethereum is the difference between a few hundred gas and tens of thousands per iteration. The persistence/scratch distinction is not academic tidiness; it is the main lever you have over what a contract costs to run.

┌──────────────── one contract call ────────────────┐
│ │
│ stack + memory ── locals: sum, i (gone at end) │
│ │ │
│ ▼ (final write) │
│ storage slot ── total ─────────────┐ │
└──────────────────────────────────────┼───────────┘
world state (survives the call)

Under the hood — what “expensive” means for storage

Section titled “Under the hood — what “expensive” means for storage”

Storage is dear because every node on the network must keep it forever and fold it into the state tries covered back in Storage Slot Layout and the state and tries part. The EVM charges accordingly. As of the gas schedule current in 2024, writing a storage slot from zero to non-zero (SSTORE) costs on the order of 20,000 gas, changing an already-non-zero slot costs roughly 5,000 gas, and even reading a cold slot (SLOAD) costs about 2,100 gas — against a mere 3 gas for an ADD on the stack. Those exact numbers have been retuned by several EIPs and may shift again, so treat them as orders of magnitude, not constants. The lesson is stable regardless of the exact figures: storage is the scarcest resource a contract touches, and good Solidity minimizes how often it is written. The gas and fees part makes this quantitative.

Function visibility: who is allowed to call this?

Section titled “Function visibility: who is allowed to call this?”

Every function has a visibility keyword, and it is a real security boundary, not documentation. It answers: can an outside stranger call this directly, or only the contract’s own code?

VisibilityCallable from a transaction / other contract?Callable from inside this contract?Typical use
publicyesyesthe contract’s outward API
externalyesno (not as a plain internal call)API meant only for outside callers; can be cheaper for large array arguments
internalnoyes (and by contracts that inherit it)shared helper logic
privatenoyes (this contract only)implementation detail

The default matters enormously. If you forget to mark a sensitive function, older Solidity treated functions as public unless told otherwise — meaning a function you meant as an internal helper was silently exposed to the entire world. That exact mistake has drained real contracts (see the incident below). Modern Solidity (0.5.0 and later) requires you to state visibility explicitly, precisely because the safe-by-omission default was a footgun.

State mutability: does this function change the world?

Section titled “State mutability: does this function change the world?”

Alongside visibility, functions carry a mutability promise about whether they touch state:

  • view — the function may read state but promises not to modify it. get() above is a view.
  • pure — the function reads and writes nothing on-chain; it depends only on its arguments. A helper like function double(uint256 x) public pure returns (uint256) { return x * 2; } is pure.
  • (no keyword) — the function may change state. increment() is state-changing.

This distinction has a concrete, money-relevant consequence. A state-changing call must be sent as a transaction: it needs a sender, a signature, a nonce, and gas, and it costs ether to execute because it alters the world every node must store. A view or pure call, by contrast, changes nothing, so a node can answer it locally — an eth_call against its own copy of the state — without a transaction, without consensus, and without a gas fee to the caller. Reading count is free; incrementing it is not. That asymmetry falls straight out of the throughline: you only pay strangers to agree when you are asking them to change the shared world; merely asking what it currently is is a private lookup. The compiler also enforces the promise — try to SSTORE inside a view function and compilation fails — which turns “this getter is safe to call for free” into a guarantee rather than a hope.

The constructor: code that runs once and then disappears

Section titled “The constructor: code that runs once and then disappears”

The constructor is special, and its specialness is easy to miss until you connect it to the previous page’s code field.

When you deploy, you send a transaction carrying initialization bytecode (often called init code or creation code). The EVM runs that init code once. Its job is twofold: execute the constructor’s logic — setting up initial state variables, like count = start above — and then return the runtime bytecode, the bytes that actually get installed in the new account’s code field. This means:

  • The constructor’s logic runs exactly once, at creation, inside the deploy transaction.
  • Constructor logic is not part of the deployed contract. It is thrown away after deployment. If you look up the account’s code afterward, the constructor’s opcodes are not there — only the runtime code that handles ordinary calls.
deploy tx ── carries ──► init code (creation code)
│ EVM runs it ONCE
├─► runs constructor body (e.g. count = start)
└─► RETURNs runtime bytecode
account.code = <runtime bytecode only>
(the constructor is gone — never stored)

This is exactly the deploy step the companion ethmini chain models when a Deploy transaction sets account.code = code for a freshly derived address. Solidity simply adds the wrinkle that the bytes you send (init code) are not the bytes that get stored (runtime code) — the init code is a little program whose output is the real program. The mechanics of address derivation and the two deployment opcodes are the subject of Deployment: CREATE and CREATE2.

The compile step: from source to the account’s code field

Section titled “The compile step: from source to the account’s code field”

Putting it together, here is the whole pipeline the compiler solc runs:

Terminal window
# Compile Counter.sol and emit its runtime bytecode + ABI
solc --bin-runtime --abi Counter.sol
# Or, with the Foundry toolchain:
forge build

The compiler produces two artifacts you care about:

  1. Bytecode — including the init/creation code that a deploy transaction carries, and the runtime bytecode that becomes account.code. This is the exact byte string the previous page taught you to read out of an account. The compiler is the only thing standing between your contract Counter { … } and those bytes.
  2. The ABI — a JSON description of the contract’s functions and their argument types. It is what lets an outside caller know that “increment” corresponds to a particular 4-byte selector. That is the entire subject of the next page, The ABI and 4-Byte Function Selectors.

So the chain of custody is complete: you write Solidity, solc emits bytecode, a deploy transaction runs the init code, and the runtime bytecode lands in the account’s code field — where it will be executed, unchanged, by every future call, forever. Human-readable intent in; immutable machine bytes out.

  • Why does it exist? The EVM only speaks bytecode, which is unwritable at scale by hand. Solidity exists to let humans express contract logic in named state variables and functions and have a compiler mechanically produce correct, deployable bytecode.
  • What problem does it solve? It closes the gap between intent (“keep a counter, let anyone increment it”) and the immutable bytes that intent must become — while encoding safety-critical decisions (who can call what, what costs gas) directly in the language via visibility and mutability.
  • What are the trade-offs? Familiar, flexible syntax makes Solidity productive but leaves sharp edges — permissive defaults, silent overflow in old versions, delegatecall foot-guns — that have caused real losses. Vyper trades expressiveness for fewer of those edges; both hit the same EVM.
  • When should I avoid it? When you need auditable simplicity over features, a stricter language like Vyper (or hand-written Yul/assembly for a tiny, gas-critical routine) can be the safer choice. And nothing forces you to write a contract at all if a plain externally-owned account and off-chain logic suffice.
  • What breaks if I remove it? Nothing at the protocol level — the EVM runs bytecode regardless. But practically, contract development at today’s scale collapses: you would be back to hand-assembling opcodes, counting stack slots, and shipping immutable bugs, which is exactly the error-prone world the compiler exists to abolish.
  1. Solidity and Vyper are different languages, yet the EVM treats a contract written in either identically. Why — what do both compilers ultimately produce, and what does that tell you about where the “language” actually matters?
  2. count is a state variable and sum (inside a function) is a local. Where does each physically live, which one survives after the call returns, and why does that distinction dominate a contract’s gas cost?
  3. A function marked view can be called without sending a transaction or paying a gas fee, while a state-changing function cannot. Explain this asymmetry in terms of the book’s throughline — when do you actually have to pay strangers?
  4. You deploy Counter, then look up the deployed account’s code field. The constructor’s logic is not in it. Where did the constructor run, when, and why is its bytecode absent from the deployed runtime code?
  5. The Parity multisig freeze came down to a single wrong keyword. Which category of keyword, what went wrong, and why did immutability make the mistake unrecoverable?
Show answers
  1. Both solc (Solidity) and vyper (Vyper) compile down to EVM bytecode — a sequence of opcodes. The EVM executes bytecode and neither knows nor cares about the source language. So the language matters only at authoring time (readability, safety features, defaults); by the time code is deployed, all that survives is bytes in the account’s code field.
  2. count lives in the account’s storage, part of the persistent world state, and survives after the call. sum lives on the stack/in memory, scratch space that vanishes when the call returns. Storage writes are the most expensive operation a contract performs (tens of thousands of gas versus a few gas for stack arithmetic), so how often you touch storage is the main driver of gas cost.
  3. A view/pure call changes nothing, so any single node can answer it locally against its own copy of the state — no transaction, no consensus, no fee. A state-changing call alters the shared world every node must store and agree on, so it must be a signed, gas-paid transaction. You pay strangers only when you ask them to change the shared state; merely reading it is a private, free lookup.
  4. The constructor ran once, at deploy time, inside the deploy transaction — as part of the init (creation) code the transaction carried. That init code executed the constructor’s logic and then returned the runtime bytecode, which is what gets stored in account.code. The constructor’s own opcodes are discarded after creation, so they never appear in the deployed runtime code.
  5. A visibility keyword. A critical initialization function on the shared library was left callable by anyone instead of restricted, so a stranger claimed ownership and selfdestructed the library that ~500 wallets depended on. Because deployed code is immutable, there was no way to patch the bug or restore the library — the ~513,000 ETH was frozen permanently.