Solana: Instructions and Messages
Solana applications run through a clean and deterministic model: programs expose instructions, and every transaction bundles those instructions into a signed message that validators can verify and execute in parallel.
This article walks through instructions, messages, legacy formats, and the newer versioned messages powered by Address Lookup Tables, giving you a clear mental model of how Solana defines “what should happen” in every transaction.
Learn more about Solana architecture from our articles
- Architecture & Parallel Transactions
- Account Model and Transactions
- Anchor Accounts: Seeds, Bumps, PDAs
- Instructions and Messages
Solana executes transactions through a simple structure: instructions define what to run, messages define everything needed to run it, and signatures prove who approved it. Legacy messages list all accounts inline, while v0 messages use Address Lookup Tables so complex DeFi transactions can reference many more accounts without hitting Solana’s strict size limits.
Instructions
Instructions are the core unit of execution on Solana. Think of each instruction as a function call exposed by an on-chain program. Every program defines its own instruction set, the specific actions it can perform. When you interact with the network, you don’t call programs directly, you package one or more of their instructions into a transaction, sign it, and submit it for execution.
Instruction consists of
- ProgramId
- Accounts
- Instruction-specific data
ProgramId
The on-chain Id of the program (address) that contains the logic of the instruction
Accounts
Each instruction includes an array of AccountMeta entries, metadata describing every account it will read from or write to. By explicitly listing these accounts, Solana’s runtime can determine which instructions are independent and safely execute them in parallel, as long as they don’t modify the same account.

is_signer: Set totrueif the account must sign the transactionis_writable: Set totrueif the instruction modifies the account’s datapubkey: The account’s public key address
Data
The instruction’s data field is a sequence of bytes that tells the program which specific function to execute and includes the parameters needed for that call.
Example (Simple 0.01SOL Transfer)
{
“program_id”: “11111111111111111111111111111111”,
“accounts”: [
{
“pubkey”: “6uR7N6oDgE3vJXvM6Eh4xVHw2g7o7YhA7FJxC4pXcZtT”,
“is_signer”: true,
“is_writable”: true
},
{
“pubkey”: “3Nq8yVbGz7KpYw9sT6rF2LmHc4Qz5XvA1uJd8BvCzRy”,
“is_signer”: false,
“is_writable”: true
}
],
“data”: [2, 0, 0, 0, 128, 150, 152, 0, 0, 0, 0, 0]
}program_id
That’s the System Program Solana’s built-in program for managing native SOL transfers, creating accounts, and assigning ownership. Every validator includes this program by default. This tells the runtime Call the System Program to execute one of its functions
accounts:
6uR7…ZtT: Sender, must sign and can be modified (lamports will be deducted).
3Nq8…zRy: Recipient, writable because lamports will be added, but no signature required.
data:
Each data variant encoded as [variant_index:u32][variant_payload]
in our case:
pub enum SystemInstruction {
CreateAccount { ... },
Assign { ... },
Transfer { lamports: u64 },
...
}Transfer variant is at index 2 and its payload is u64 (8-bytes)
[2, 0, 0, 0, 128, 150, 152, 0, 0, 0, 0, 0]
which is:
[02 00 00 00 | 80 96 98 00 00 00 00 00] // 4 + 8 = 12[0:4) bytes
- Type: u32
- Value: 2 : in this program source code we can see that Transfer is at index 2 in the SystemInstruction enum
[4:12) bytes
- Type: u64
- Value: 10000000 lamports
[2, 0, 0, 0] → discriminant = 2
[128, 150, 152, 0, 0, 0, 0, 0] → bytes to hex
[80 96 98 00 00 00 00 00] → hex representation
[0x00000000989680] → 0x00989680 = 10_000_000 lamports (0.01 SOL)Messages
A transaction on Solana is more than just a list of instructions it’s a message signed by one or more accounts. The message defines exactly what will run, which accounts are involved, and which recent blockhash anchors it to the chain’s current state.
Solana signs the message, not each instruction individually. That design makes transactions compact and verifiable: a validator only needs to check that the signers authorized the same message bytes the network received.
Once signatures are verified, the runtime executes the instructions in order and commits all changes atomically either everything succeeds or the whole transaction reverts.
In other words:
- The message is the canonical record of “what should happen”
- The signature just proves “who approved it”
Legacy and Versioned Messages
Originally, all Solana transactions used a single message format, now referred to as the legacy message. It worked well for simple transfers and small programs, but every transaction must list all accounts it will read or write (For the purpose of parallel execution).
Since each account address is 32 bytes, the total message size grows quickly, and Solana enforces a strict upper bound (≈ 1232 bytes for serialized transactions) to fit inside the network’s MTU. That limited legacy transactions to about 35 accounts, which became a problem for complex, composable DeFi protocols that interact with many programs at once.
To solve this, Solana introduced versioned transactions, starting with v0 messages, along with a new on-chain program called the Address Lookup Table (ALT) program.
Lookup tables let developers store groups of addresses on-chain and reference them later by small 1-byte indexes instead of full 32-byte keys. The transaction can then “look up” additional accounts during execution without exceeding the size limit.
Legacy Messages
pub struct Message {
pub header: MessageHeader,
pub account_keys: Vec<Address>,
pub recent_blockhash: Hash,
pub instructions: Vec<CompiledInstruction>,
}
pub struct MessageHeader {
pub num_required_signatures: u8,
pub num_readonly_signed_accounts: u8,
pub num_readonly_unsigned_accounts: u8,
}
// As learned previously
pub struct CompiledInstruction {
pub program_id_index: u8,
pub accounts: Vec<u8>,
pub data: Vec<u8>,
}Note: the
#[serde(with = “short_vec”)]is important and we will see later why
can hold about 35 accounts (35 * 32(address size) = 1120bytes out of 1232) we can see that it is holds a fixed header, accounts, blockhash, instructions and can be used for simple or medium transactions.
Versioned Messages
pub enum VersionedMessage {
Legacy(LegacyMessage),
V0(v0::Message),
}
pub struct Message {
pub header: MessageHeader,
pub account_keys: Vec<Pubkey>,
pub recent_blockhash: Hash,
pub instructions: Vec<CompiledInstruction>,
/// List of address table lookups used to load additional accounts
/// for this transaction.
#[serde(with = “short_vec”)]
pub address_table_lookups: Vec<MessageAddressTableLookup>,
}
pub struct MessageAddressTableLookup {
pub account_key: Pubkey,
#[serde(with = “short_vec”)]
pub writable_indexes: Vec<u8>,
#[serde(with = “short_vec”)]
pub readonly_indexes: Vec<u8>,
}Transactions must fit in ~1232 bytes of packet payload. Listing every account inline (32 bytes each) caps legacy messages at ~35 accounts after signatures/overhead. V0 introduces Address Lookup Tables (ALTs) so a message can reference many accounts by 1-byte indexes instead of inlining 32-byte pubkeys.
How address lookup works
At runtime, the validator constructs a single resolved account list that instructions index into:
resolved_keys =
[ message.account_keys
, looked_up_writable_keys (from all ALTs, in order)
, looked_up_readonly_keys (from all ALTs, in order)
]Each MessageAddressTableLookup points to a specific on-chain ALT (account_key).
writable_indexes and readonly_indexes are u8 indexes into that ALT’s stored addresses.
The runtime fetches those pubkeys and appends them to resolved_keys in two groups:
- all writable lookups (preserves order across tables)
- all readonly lookups (preserves order across tables).
program_id and accounts still point into this combined list exactly like legacy messages.
how v0 is identified
Versioned transactions use a leading version bit in the message encoding:
- If the top bit of the first byte is set, the remaining bits encode a version number (v0 = 0).
- If not set, it’s treated as a legacy message.
You won’t usually touch this directly, SDKs handle it but it’s why v0 and legacy can coexist.
Size implications
v0 adds small overhead but dramatically reduces inline key bytes when many accounts are needed
Added overhead per transaction (approx) this is at least sizes:
- +1 byte: version tag
- +1 byte: address_table_lookups length
- +34 bytes per lookup table (32 for table pubkey + 1 + 1 for lengths of writable/readonly index arrays)
- +1 byte per looked-up index (each index into the ALT)
Saved space: each looked-up address replaces a 32-byte inline pubkey with a 1-byte index in the message.
When you reference dozens/hundreds of accounts, this tradeoff wins easily
Limits and rules to remember
- 256 max entries per ALT. Indexes are u8.
- Up to 256 unique accounts can be loaded overall (since compiled instruction account indexes are u8 as well).
- Signers cannot come from ALTs. All signer keys must appear in account_keys so their signatures can be verified efficiently.
- No duplicates. The same account may not be loaded more than once across account_keys and ALT lookups.
- ALT availability: Newly appended ALT entries become usable after one slot (warm-up).
- ALT lifecycle: Tables must be rent-exempt, are append-only, can be deactivated, and only closed after a cooldown to avoid censorship/race issues
Lets make sense of the numbers and why those are the limits
Lets look at the confusing statements from before
256 max entries per ALT. Indexes are u8:
Each Address Lookup Table (ALT) can store up to 256 addresses because the indexes used to reference them are u8 (1 byte).
pub writable_indexes: Vec<u8>,
pub readonly_indexes: Vec<u8>,Each element in those vectors is a single byte → range 0–255.
So when we say:
writable_indexes = [0, 2, 4, 6]the runtime loads the 0th, 2nd, 4th, and 6th addresses from that table’s on-chain storage. Hence Max 256 because u8 can encode 0–255 possible values, means 256 unique entries per lookup table
(this is impossible: writable_indexes = [0, 2, 310]).
1 byte: address_table_lookups length
The first question that can come up to mind is, why only the lenght only 1 byte if it is a vector which might be unlimited. the magic is in#[serde(with = “short_vec”)] which means
This Vec<…> in Solana’s serialized structs uses a short_vec format.
That’s how Bincode (via the short_vec module) serializes variable-length arrays efficientlyFor small vectors (length < 128), the length fits in a single byte
[length (1-9 bytes)][elements...]
If you have 3 lookup tables, that length byte would literally contain 0x03.
It means there’s one byte added to encode how many lookup tables are attached, before serializing their contents.
34 bytes per lookup table (32 + 1 + 1)
This is the same the prev statement, take a look that
pub struct MessageAddressTableLookup {
pub account_key: Pubkey,
#[serde(with = “short_vec”)]
pub writable_indexes: Vec<u8>,
#[serde(with = “short_vec”)]
pub readonly_indexes: Vec<u8>,
}serialized with #[serde(with = “short_vec”)] means that every MessageAddressTableLookup needs at least 34 bytes + N-bytes for each entry.
MessageHeader
The header tells the runtime how many accounts must sign, and which accounts are read-only.
It’s the preamble the runtime uses to understand how to interpret the account_keys slice.
num_required_signatures (u8)
How many of the first account_keys must provide signatures.
- If this is 1, the first key in account_keys is the signer.
- If this is 2, the first two keys are signers, etc.
These signatures come from the transaction’s signature array. Every signer must match with its position in the account_keys.
num_readonly_signed_accounts (u8)
Among the signer accounts (the first num_required_signatures), how many of them are read-only. They can be read, but the program cannot modify their account data or lamports. This prevents accidental or malicious modification of signer accounts when they don’t need to be writable.
Example:
- A user signs a transaction using their wallet (signer),
- But that wallet account is never written to so it should remain read-only.
num_readonly_unsigned_accounts (u8)
Among the non-signers (the remaining accounts), how many of them are read-only.
Why this matters:
Some accounts are passed to a program as read-only e.g., system program, token program, metadata program. Marking them read-only improves security and performance.
recent_blockhash
This field prevents replay attacks and sets a liveness requirement.
Solana does not use nonces.
Instead, every transaction must include a recent blockhash from the last ~150 blocks (≈ 2 minutes).
Why it’s required
Anti-replay
If someone copies your signed transaction and tries to submit it again, it will be rejected because the blockhash is too old.
Transaction expiration
Solana requires transactions to be “fresh.”
If the blockhash is too old, the validator won’t accept the transaction.
Fork commitment
Validators agree that transactions built on a recent blockhash belong to the current fork, reducing ambiguity.
Summary
You should now understand how Solana structures the logic behind every transaction. An instruction defines what to run, the program, accounts, and data, while a message bundles those instructions together into an atomic unit that can be signed and verified. Legacy messages work for most cases, but v0 messages extend the format with Address Lookup Tables, allowing many more accounts to be referenced through compact 1-byte indexes.
In short, you now know how Solana encodes what happens in a transaction.
Reliable Solana RPC infrastructure
Getting started with Solana on Chainstack is fast and straightforward. Developers can deploy a reliable Solana node within seconds through an intuitive Console — no complex setup or hardware management required.
Chainstack provides low-latency Solana RPC access and real-time gRPC data streaming via Yellowstone Geyser Plugin, ensuring seamless connectivity for building, testing, and scaling DeFi, analytics, and trading applications. With Solana low-latency endpoints powered by global infrastructure, you can achieve lightning-fast response times and consistent performance across regions.
Start for free, connect your app to a reliable Solana RPC endpoint, and experience how easy it is to build and scale on Solana with Chainstack – one of the best RPC providers.





