# The 7 Bytes That Weren't in the IDL: How #[repr(C)] Padding Breaks a Borsh Client

A single `u64` added seven bytes of padding the compiler knew about and the client didn't. Here is why, and how to predict it for any `#[repr(C)]` struct.

## The failure

I added one field to an instruction-args struct, a plain `u64`, and my generated client stopped working. Same logical arguments as before (an owner, an amount, a bump), correct values, correct order. The program rejected it anyway. When I built the exact same instruction by hand, it went through.

Here is what that looked like against the program, verbatim from my own debugging run:

```text
[1] client-built ix: 41 arg bytes (borsh). Program expects size_of::<Initialize>() = 48.
    Transaction simulation failed: Error processing Instruction 0: custom program error: 0x0
  Program CbMX... invoke [1]
  Program log: initialize
  Program CbMX... failed: custom program error: 0x0
 
[2] hand-built repr(C) ix: 48 arg bytes.
    SUCCESS
```

Forty-one bytes rejected. Forty-eight bytes accepted. The difference is not the data. It is seven bytes that exist in the program's view of the struct and do not exist in the client's. Those seven bytes are trailing padding, and nothing in the generated IDL told the client to send them.

This post is the walk from that `custom program error: 0x0` back to the layout rule that explains it, so that next time you can predict the padding before it costs you a debugging session.

## TL;DR

*   A zero-copy read (`&*(ptr as *const T)`) requires the exact `#[repr(C)]` byte layout, which includes any padding, so it demands `size_of::<T>()` bytes.
    
*   A borsh client serializes fields packed, with no padding, so it sends `Σ field sizes` bytes.
    
*   They agree only when the struct has no implicit padding, that is, when `size_of::<T>() == Σ field sizes`.
    
*   Padding is a function of field alignments, field order, and total size. It is not caused by "having a big field" on its own.
    
*   The reliable fixes: use only align-1 fields, or order fields by descending alignment, or expose the gap as an explicit `_padding: [u8; N]` field so it appears in the IDL and the client sends it too.
    
*   Verify with `size_of::<T>() == Σ field sizes`. A compile-time `assert!` on that catches the regression at build time.
    

## Why this bites: two layouts that must agree

On Solana, an on-chain program and its off-chain client are two programs that have to agree on a byte buffer. In a pinocchio-style program, instruction arguments are read *zero-copy*: the program takes the raw instruction-data bytes and casts a pointer straight to your struct, no deserialization step. The general technique is what pinocchio is built around. As [an article from Anza](https://www.anza.xyz/blog/febo-on-pinocchio-p-token-and-pushing-solanas-limits) (by Febo, a core engineer at Anza) puts it, zero-copy means reading data straight from the input buffer instead of deserializing it into owned types, which for fixed-layout data avoids a large amount of unnecessary work. That cast only produces correct fields if the incoming bytes are laid out exactly as the compiler laid out the struct, which means the full `#[repr(C)]` layout, padding and all. A zero-copy read of `T` wants `size_of::<T>()` bytes, in `T`'s exact in-memory shape.

The generated client, on the other hand, serializes the same arguments with borsh. Borsh packs fields back to back with no padding. So the client emits `Σ field sizes` bytes.

Those two numbers are equal only when the struct carries no implicit padding. When they differ, the client and the program disagree about how many bytes the arguments are, and the arguments are read wrong or, as above, rejected outright. Everything else in this post is about predicting that one equality.

## Size and alignment: the two numbers

Every type in Rust has two numbers that drive layout:

*   **size**: how many bytes it occupies.
    
*   **alignment**: a value of this type must start at an offset that is a multiple of this number. Padding exists for exactly one reason: to satisfy alignment. If nothing needed to start on a boundary, nothing would ever be padded.
    

## Alignment of the types you actually use

The layout algorithm below runs on the alignments of the primitives you build with, so here they are. The one that trips people is at the bottom: a 32-byte pubkey is align 1, not align 32, because it is a byte array.

| Type | size | align |
| --- | --- | --- |
| `bool`, `u8`, `i8` | 1 | **1** |
| `u16`, `i16` | 2 | 2 |
| `u32`, `i32`, `f32` | 4 | 4 |
| `u64`, `i64`, `f64` | 8 | 8 |
| `u128`, `i128` | 16 | 16 |
| `[T; N]` | N × size(T) | align(T) |
| `Address` / `Pubkey` (`[u8; 32]` newtype) | 32 | **1** |
| a struct | (see below) | max alignment of its fields |

`Address` and `Pubkey` are byte arrays: align 1, size 32. They never cause padding on their own, which is why an all-pubkey struct is always safe to read zero-copy.

## The `#[repr(C)]` layout algorithm

`#[repr(C)]` gives you a defined, predictable layout (unlike the default `repr(Rust)`, which the compiler may reorder). The whole thing is this loop:

```text
offset = 0
for each field, in declaration order:
    offset = round_up(offset, align(field))   # gap here = INTERNAL padding
    place field at offset
    offset += size(field)
 
struct_align = max(align(field) for all fields)
struct_size  = round_up(offset, struct_align) # gap here = TRAILING padding
```

`round_up(x, a)` is the smallest multiple of `a` that is `>= x`.

Three consequences fall directly out of this, and they are the load-bearing facts for the rest of the post:

*   `size_of::<T>()` equals `struct_size`, which includes all padding.
    
*   The borsh size equals the sum of the field sizes, with no padding.
    
*   Therefore **implicit padding is present if and only if** `size_of::<T>() != Σ field sizes`**.** That last line is the entire test. If those two numbers match, the client and the program agree. If they do not, they do not.
    

## Two kinds of padding: internal and trailing

The algorithm inserts padding in two places, and they have different fixes, so it is worth naming them.

**Internal padding** sits between fields. A field whose alignment is greater than 1 lands on an offset that is not a multiple of its alignment, so the compiler inserts bytes before it:

```text
struct { a: u8, b: u64 }
 offset 0: a            (1 byte)
 offset 1..8: PAD       (7 bytes; u64 needs offset % 8 == 0)
 offset 8: b            (8 bytes)
 size = 16, sum = 9   -> 7 bytes internal padding
```

**Trailing padding** sits at the end. The final size is not a multiple of the struct's own alignment, so bytes are appended, so that an array of the struct keeps every element aligned:

```text
struct { a: u64, b: u8 }
 offset 0: a            (8 bytes)
 offset 8: b            (1 byte)  -> offset now 9
 struct_align = 8, round_up(9, 8) = 16
 size = 16, sum = 9   -> 7 bytes trailing padding
```

Notice the same two fields, in either order, still cost 7 bytes of padding. Reordering moved *where* the padding lands (internal vs trailing) but not *whether* it exists. That is a useful warning: field ordering is a real tool, but it is not a guaranteed fix by itself.

## When padding appears: the rules

Three cases cover every struct, and you can sort any struct into one without running the compiler.

**No padding, guaranteed:** every field has align 1 (`u8`, `i8`, `bool`, `[u8; N]`, `Address`, `Pubkey`), in any order. Nothing ever needs a boundary, so nothing is ever inserted.

**No padding, if you are careful:** fields are placed so each aligned field lands on its boundary with no gap, and the total size is already a multiple of the largest alignment. In practice that means order fields by descending alignment (which removes internal padding), and arrange for the running total to reach a multiple of the biggest alignment on its own, or add an explicit `_padding` field to reach it (which removes trailing padding).

**Padding appears when** either a field with align > 1 follows fields whose sizes do not add up to a multiple of that field's alignment (internal padding), or the total size is not a multiple of the struct's max alignment (trailing padding).

So padding is a function of field alignments, order, and total size, not of "having a big field." A `u64` alone in a struct pads nothing (`{ u64 }` is size 8). A `u64` sitting next to a `u8` is what introduces the gap.

## A gallery of worked examples

Here is the rule applied across a spread of structs. Two of these are marked because they show up later: the safe all-align-1 case, and the one that actually broke.

```text
{ u8 }                       size 1,  sum 1    no padding
{ u64 }                      size 8,  sum 8    no padding
{ u32, u32 }                 size 8,  sum 8    no padding
{ u32, u16, u16 }            size 8,  sum 8    no padding
{ u16, u16, u32 }            size 8,  sum 8    no padding
{ Address, u8 }              size 33, sum 33   no padding   (both align 1)
{ u8, u64 }                  size 16, sum 9    7 internal
{ u64, u8 }                  size 16, sum 9    7 trailing
{ u16, u32 }                 size 8,  sum 6    2 internal
{ u32, u16 }                 size 8,  sum 6    2 trailing
{ Address, u64, u8 }         size 48, sum 41   7 trailing   <- the bug below
{ u8, u32, u8 }              size 12, sum 6    (3 before u32) + (3 trailing) = 6
{ u128, u8 }                 size 32, sum 17   15 trailing
```

That `{ Address, u64, u8 }` line, size 48, sum 41, seven bytes of trailing padding, is the struct that produced the `custom program error: 0x0`.

## Back to the bug

The struct started as a scaffold instruction-args type. I added the `amount: u64` in the middle:

```rust
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, shank::ShankType)]
pub struct Initialize {
    pub owner: Address,   // 32
    pub amount: u64,      // 8   <- added this
    pub bump: u8,         // 1
}
```

Adding the `u64` raised the struct's alignment to 8. Now walk the layout:

| field | align | offset before | pad | at | after |
| --- | --- | --- | --- | --- | --- |
| `owner` | 1 | 0 | 0 | 0 | 32 |
| `amount` | 8 | 32 | 0 (32 is 8-aligned) | 32 | 40 |
| `bump` | 1 | 40 | 0 | 40 | 41 |
| end |  | 41 | struct align 8 -> 41 rounds to 48 |  | 48 |

So `size_of::<Initialize>()` is 48, while borsh is `32 + 8 + 1 = 41`. Seven bytes of trailing padding, exactly the amount needed to round 41 up to the next multiple of 8. The `owner` and `amount` fit perfectly (32 is already 8-aligned, so there is no *internal* padding); the whole cost lands at the tail.

The program reads the args zero-copy, and the read enforces the length:

```rust
pub unsafe fn load_ix_data<T>(bytes: &[u8]) -> Result<&T, ProgramError> {
    if bytes.len() != T::LEN {                 // T::LEN = size_of::<Initialize>() = 48
        return Err(MyProgramError::InvalidInstructionData.into());  // -> custom error 0x0
    }
    Ok(&*(bytes.as_ptr() as *const T))
}
```

The client sent 41 borsh bytes. `41 != 48`, so the guard returns `InvalidInstructionData`, which surfaces as `custom program error: 0x0`. The seven padding bytes are the whole problem: they are not in the IDL, so the client never serialized them.

One nuance worth keeping, because it tells you *where* padding bites. The account (state) struct in the same program, holding just `owner: Address` and `amount: u64`, is size 40 and borsh 40, padding-free, so its account path round-trips fine. Both the args struct and the account struct are read on-chain by the same family of raw-pointer cast; the account struct simply happens to have no padding, so its borsh client agrees. Padding broke the instruction-args struct, not the account struct, and it was the layout of the struct that decided it, not the read mechanism.

## The fix, and the guards

The fix is to stop hiding the padding. Turn those seven trailing bytes into a real field:

```rust
#[repr(C)]
pub struct Initialize {
    pub owner: Address,     // 32
    pub amount: u64,        // 8
    pub bump: u8,           // 1
    pub _padding: [u8; 7],  // 7   <- explicit; now a real field, and in the IDL
}
```

| field | align | at | after |
| --- | --- | --- | --- |
| `owner` | 1 | 0 | 32 |
| `amount` | 8 | 32 | 40 |
| `bump` | 1 | 40 | 41 |
| `_padding` | 1 | 41 | 48 |
| end |  |  | 48 (48 % 8 = 0, no trailing) |

Now `size_of` is 48 and the field sizes sum to `32 + 8 + 1 + 7 = 48`. Implicit padding is zero. Because `_padding` is a declared field, shank sees it, so it lands in the IDL, so the client serializes it too. The client now sends 48 bytes, exactly what `load_ix_data` expects. Both sides agree. Step `[2]` from the opening log already proved this shape works: a hand-built 48-byte payload (owner, amount, bump, then seven zero bytes) executed on-chain, and that is byte-for-byte what the client sends once `_padding: [u8; 7]` is a real field.

Because I would rather catch this at build time than in a simulation log, `pinoc` now ships two guards, and both are worth showing because they encode the rule directly.

The scaffold emits a compile-time assert next to the struct. On the plain scaffold struct (`Initialize { owner: Address, bump: u8 }`, size 33) it reads:

```rust
const _: () = assert!(
    core::mem::size_of::<Initialize>() == 32 + 1,
    "Initialize has implicit padding; add explicit _padding fields so its #[repr(C)] layout matches the borsh client"
);
```

That `== 32 + 1` is the packed size the layout is supposed to have. The moment you add `amount: u64`, `size_of` becomes 48, the equality is now `48 == 33`, and the build fails at const-eval:

```text
error[E0080]: evaluation panicked: Initialize has implicit padding; add explicit _padding fields so its #[repr(C)] layout matches the borsh client
  --> src/instructions/initialize.rs:28:15
   |
28 |   const _: () = assert!(
   |  _______________^
29 | |     core::mem::size_of::<Initialize>() == 32 + 1,
30 | |     "Initialize has implicit padding; add explicit _padding fields so its #[repr(C)] layout matches the borsh client"
31 | | );
   | |_^ evaluation of `instructions::initialize::_` failed here
 
For more information about this error, try `rustc --explain E0080`.
error: could not compile `e0080_repro` (lib) due to 1 previous error
```

(The `error[E0080]: evaluation panicked: <message>` phrasing is toolchain-dependent; this is from the current Solana platform-tools rustc. Older compilers word const-assert failures differently, so pin your toolchain if you quote it. The crate name and line numbers above are from a scratch reproduction, not the committed template.)

For programs that do not carry the assert, `pinoc build` and `pinoc idl` run a lint that finds padded `#[repr(C)]` structs and prints:

```text
⚠️  `Initialize` is #[repr(C)] with 7 byte(s) of implicit padding (layout size 48 vs packed 41). The generated client (de)serializes it as packed borsh, so it won't round-trip on-chain. Add explicit `_padding: [u8; 7]` field(s).
```

Both guards landed together and are on `main`. The source is public if you want to read the exact emitter and detection logic: [github.com/A91y/pinoc](https://github.com/A91y/pinoc) (the assert lives in the scaffold templates, the lint in `src/idl/`, merged in commit `04031fb`).

One more note on explicit padding, because it is easy to over-learn the lesson. Explicit `_padding` is not always fixing an alignment problem. The Solana Foundation `pinocchio-counter` uses it on an all-align-1 struct:

```rust
#[repr(C)]
struct Counter {
    bump: u8,
    _padding: [u8; 7],   // EXPLICIT padding: a real field, appears in the IDL
    authority: Address,  // 32
    count: u8,
}
```

Here `authority` is align 1, so nothing forces a gap. The `_padding` is a deliberate design choice, not an alignment repair. The underlying principle is the same either way: make every gap an explicit field so `size_of == Σ field sizes`, whether the gap is demanded by alignment or chosen by you.

## The checklist

When you write a `#[repr(C)]` struct that will be read zero-copy on-chain and serialized by a borsh client:

1.  **Prefer align-1 fields.** `u8`, `[u8; N]`, `Address`. If you want zero risk, store integers as `[u8; 8]` and reach for `u64::from_le_bytes(...)` at the read site. Align-1 structs never pad.
    
2.  **Otherwise, order fields by descending alignment** (biggest align first) to kill internal padding.
    
3.  **Add an explicit** `_padding: [u8; N]` to round the total up to the max alignment, killing trailing padding. Now the padding is a real IDL field and the client sends it.
    
4.  **Verify** `size_of::<T>() == Σ field sizes`**.** A `const _: () = assert!(...)` on that equality turns this whole class of bug into a build error.
    

## Test yourself

Predict `size_of`, `Σ field sizes`, and the padding for each. Answers below.

1.  `{ u8, u8, u16 }`
    
2.  `{ u16, u8, u8 }`
    
3.  `{ u32, u8 }`
    
4.  `{ Address, u32 }`
    
5.  `{ u64, u32, u32 }`
    
6.  `{ u8, u128 }` Answers:
    
7.  size 4, sum 4, **0** (`u16` at offset 2 is already aligned; total 4 is a multiple of 2).
    
8.  size 4, sum 4, **0** (`u16` at 0, `u8` at 2, `u8` at 3; total 4).
    
9.  size 8, sum 5, **3 trailing** (`u32` at 0, `u8` at 4 gives 5, round up to 8).
    
10.  size 36, sum 36, **0** (`Address` align 1 at 0..32, `u32` needs align 4, and 32 % 4 == 0 so no gap; total 36 is a multiple of 4).
     
11.  size 16, sum 16, **0** (`u64` at 0, `u32` at 8, `u32` at 12 gives 16, a multiple of 8).
     
12.  size 32, sum 17, **15** (`u8` at 0, `u128` needs align 16 so it lands at 16, giving 15 bytes of internal padding; total 32).
     

## Takeaway

The bug was never in the data. It was in the seven bytes the compiler adds and the IDL does not describe. A zero-copy read wants the full `#[repr(C)]` layout; a borsh client sends packed fields; they agree only when `size_of::<T>() == Σ field sizes`. Run that layout loop in your head when you write the struct, or assert the equality so the compiler runs it for you. Either way it becomes a build error instead of a simulation log, which is where I wanted it.

## Why this post exists

This came out of building pinoc, my IDL and client generator for pinocchio programs. I hit the padding mismatch in my own work: the client kept getting `custom program error: 0x0` even though the arguments were correct, and it took a while of banging my head against it before the size mismatch clicked. Once it did, I figured the write-up was worth reconciling into something the community could use, since the next person to add a `u64` to a `#[repr(C)]` args struct is going to hit exactly this.

The IDL and client generation that surfaced all of this ships with pinoc v0.2.0. If you want to try it or read the layout guards for yourself, the source is here: [github.com/A91y/pinoc](https://github.com/A91y/pinoc).

If you have questions or want to poke at it, I am on X at [@AyushAgr91](https://x.com/AyushAgr91).

## Further reading

For the authoritative rules behind everything above, the Rust Reference on type layout is the source of truth: [https://doc.rust-lang.org/reference/type-layout.html](https://doc.rust-lang.org/reference/type-layout.html).

This is the first post in a series on low-level Solana and pinocchio work. More is coming :)
