State and storage: why a contract "remembers" things#
← Back to Week 5 (Blockchain track, Vyper): Guided build
The problem this solves#
In every Python script you've written so far, variables live in memory for as long as the script is running, and vanish the instant it ends. If you want a value to survive between runs, you write it to a file or a database yourself. That's such a normal fact of programming that it's easy to forget it's a choice, not a law of nature.
A smart contract doesn't work that way, and the difference matters enough that it needs its own name: state. This is the same idea the Solidity track's version of this note covers; if you've read that one, the concept here is identical. What's different is how Vyper makes you write it down.
What "state" means here#
A contract's state variables are declared at the top level of the contract, outside any function. Look at the shape of AssetRegistry (the full contract is in project/contracts/AssetRegistry.vy, but you only need this piece for now):
struct Holder:
balance: uint256
eligible: bool
registered: bool
last_updated: uint256
owner: public(address)
holders: public(HashMap[address, Holder])
owner and holders are state variables. They are not local to any one function call. Once register_holder writes a value into self.holders[some_address], that value exists as part of the contract's permanent record on the network. It's still there tomorrow, next year, whether or not anyone ever calls the contract again. Every full node running that network is keeping a copy of it, in sync, forever (or until the network itself stops existing).
That's the core idea: state is data the network itself remembers, not data your script remembers. Your Python script is a visitor that shows up, asks a question or requests a change, and leaves. The contract's state is what's still there after every visitor has gone home.
self.: Vyper's answer to "wait, is this state or not?"#
Notice every reference to owner or holders inside a function is written self.owner and self.holders[...], never bare owner or holders. Solidity lets you write a state variable's name directly inside a function, with nothing marking it as different from a local variable; you have to already know, from the declaration, which names are state and which aren't. Vyper doesn't let you do that. Every single access to a state variable, reading or writing, requires the explicit self. prefix, with no exceptions and no way to opt out.
This is a deliberate design choice, not an inconsistency to memorize around. It means you can look at any single line of a Vyper contract, in isolation, with no other context, and know immediately whether it touches the network's permanent record: if you see self., it does; if you don't, it doesn't. previous_balance: uint256 = self.holders[holder].balance reads state. previous_balance itself, once assigned, is a local variable; from that point on, previous_balance (no self.) is a plain, temporary value, and self.holders[holder].balance (with self.) is still the permanent one. The two look nothing alike on the page, which is exactly the point.
Why this costs money and a normal variable doesn't#
Every node on the network has to store and maintain a copy of contract state. That's real, ongoing cost, spread across thousands of machines, and someone has to pay for it or the network would be flooded with junk data for free. The payment mechanism is gas: every operation that changes state costs gas, and writing to a state variable (a HashMap or any other state variable) is one of the more expensive things you can do in a contract, specifically because of that permanence.
Reading state back (a @view function, called without sending a transaction) doesn't change anything, so it doesn't cost gas when you call it from outside the network the way get_holder does in the project script. Writing does, every time, because you are asking every node, permanently, to remember something new.
This is the single biggest mental shift from ordinary Python: assignment is not free. balance = 500 in Python costs nothing extra. self.holders[holder].balance = 500 inside a contract function costs gas, because it's not really "setting a variable," it's "asking a global, permanent, distributed ledger to update its records and keep them that way forever."
Local variables: no keyword needed, no aliasing allowed#
You'll see this line inside get_holder:
h: Holder = self.holders[holder]
self.holders[holder] on its own refers to the actual struct living in permanent storage. Assigning it to a local variable, h: Holder = ..., copies that struct into a plain local value that exists only for the duration of the function call and disappears afterward, exactly like a normal Python variable would. Notice there's no separate keyword marking this as a copy, the way Solidity's Holder memory h = ... needs the word memory; in Vyper, assigning a state value into a local variable is always a copy, every time, with nothing else to write or remember. There is also no way to get a live, mutable reference back into storage the way Solidity's Holder storage h = ... allows; Vyper simply doesn't have that feature. If you want to change self.holders[holder], you write to self.holders[holder] directly, every time, never through a local alias. That's one less rule to hold in your head, and one less category of aliasing bug (changing what you thought was a local copy and silently mutating storage, or the reverse) that can't happen at all in Vyper, because the language never gives you the tool to cause it.
HashMaps answer "no" by default, which is why registered exists#
HashMap[address, Holder] behaves like a Python dictionary in one way (you look values up by key) and differs from one in an important way: a Vyper HashMap has no concept of a missing key, exactly the same as a Solidity mapping. Every possible address, even one that has never been touched, already "has" a Holder struct, silently defaulted to balance: 0, eligible: False, registered: False, last_updated: 0. There's no KeyError, no .get(key, default), nothing to catch.
That's exactly why AssetRegistry has an explicit registered boolean field instead of just checking whether balance is nonzero. Without it, there would be no way to tell "this address was deliberately registered with a zero starting balance" apart from "this address was never registered at all." Any time you design a HashMap like this, ask yourself the same question: is there a meaningful difference between "never set" and "set to the zero-like value," and if so, have you given yourself an explicit way to tell them apart?
The free getter you didn't write#
Because holders is declared public(...), Vyper automatically generates a read-only function for you that looks up a single entry, taking the HashMap's key as an argument and returning the struct's fields. That's why, later, a web3.py script can call a get_holder-shaped read even though you only wrote the storage declaration, not a matching getter function, for holders itself. owner works the same way: owner: public(address) gives you a callable owner() for free.
Why this note comes first#
Access control (the next note) is really "who is allowed to trigger an expensive, permanent change to this state." Events (after that) are "a cheap side-note the network keeps about a state change, for code watching from outside." Failure modes are, almost without exception, about state being read or written in the wrong order relative to something else happening. Every one of those ideas assumes you already have a solid, intuitive answer to "what does it mean for a contract to remember something, and what does that cost." That's what this note was for.
TL;DR#
This page explains what it means for a smart contract to "remember" data between calls, why that memory costs money, and what's different about writing that down in Vyper.
- A contract's state variables live outside any function, and once you write to one, that value becomes part of the network's permanent record, not just something your current script remembers.
- Writing to a state variable costs gas, because every node on the network has to store and keep that change forever. Just reading state back (a view call) is free.
- Vyper requires
self.in front of every state variable, everywhere, with no exceptions. That's not decoration; it's the language forcing you to see, on every line, exactly which values are the network's permanent record and which are just local to this function call. - A
HashMaphas no idea of a "missing" key, exactly like Solidity's mapping. Every possible key already has a default value, so if you need to tell "never set" apart from "set to the zero value," you need your own explicit flag for that. - Marking a state variable
public(...)gives you a free, ready-made function to read it from outside, without writing one yourself.
owner: public(address) # state variable: part of the network's permanent record, not a script's memory
struct Loan:
due_date: uint256
active: bool
loans: public(HashMap[address, Loan]) # public state variable
@external
def check_out(borrower: address, due_date: uint256):
self.loans[borrower] = Loan(due_date=due_date, active=True) # writing state costs gas, self. marks it as such
@external
@view
def is_overdue(borrower: address) -> bool:
l: Loan = self.loans[borrower] # assigning into a local variable copies out of storage, no keyword needed
if not l.active: # active tells "never checked out" apart from "checked out, due_date 0"
return False
return block.timestamp > l.due_date # just reading state (a view call) costs no gas
# loans(someAddress) already works from outside, for free -- Vyper generated
# that getter automatically the moment loans was declared public.