Ankit Ranjan
Back to Deep Dives

Integers in Dart — Smi, Mint, BigInt, and Typed Buffers

Every int in Dart hides a strategy. A deep dive into how Dart stores numbers, what boxing costs, and how typed buffers let you write code that flies.

May 22, 2026 7 topics 7 quiz questions
Share:
1

Smi — when the number lives inside the pointer

Every time we write int x = 42, Dart has a choice to make. It can store our number in a fast, lightweight way, or in a heavier, more flexible way — and that choice happens automatically, behind the scenes.

We might expect this: Dart allocates an object somewhere in memory holding the value 42, and our variable x holds a reference to that object. But here's the thing — 42 is small. So small that it can fit inside the pointer itself, no allocation needed.

This is the Smi (Small Integer) trick. A pointer on a 64-bit machine is 64 bits wide. Dart steals one bit to mark whether the pointer is "a real reference to an object" or "actually the number itself". For Smi values, the value lives directly in the pointer slot.

Normal pointer (object reference) 63 bits — heap address 0x7FF8AB3D… tag 1 header value 42 heap object Smi pointer (small integer) 63 bits — the value itself 42 (encoded inline) tag 0 The value IS the pointer. No heap allocation. No indirection.

When we do arithmetic on a Smi, the CPU operates on the value directly. There's no following a pointer to find the data — the value is the pointer. This is as fast as integer math can be.

How small is "small enough"? It depends on the build. On 64-bit Dart with compressed pointers (which Flutter uses on mobile), a Smi can hold about ±1 billion. Without compressed pointers (desktop Dart), it can hold up to about ±4.6 quintillion. For most code this is more than enough.

The takeaway: Smi values are free. Zero heap allocation, zero indirection, full CPU speed.

2

Mint — when the number outgrows the pointer

So what happens when our integer doesn't fit as a Smi anymore?

int x = 1 << 62;  // way too big to be a Smi
Now Dart has to box the value. Boxing means allocating a heap object, storing the 64-bit integer inside it, and putting a real pointer to that object in our variable.

We call this a Mint (Medium Integer).

What boxing costs x = 42 (fits as Smi) x value: 42 0 ✓ 0 extra bytes ✓ no allocation ✓ invisible to GC x = 1 << 62 (boxed as Mint) x pointer 0xA1B2… 1 header + GC tags value 8 bytes Mint heap object ✗ extra allocation ✗ pointer indirection on every read ✗ GC has to track it

The transition from Smi to Mint is invisible to us in code — we still see int. But under the hood, our program just got slower and heavier. Every Mint is something the garbage collector has to track and eventually clean up. Smis don't exist as objects, so they're invisible to GC.

3

BigInt — when 64 bits aren't enough

BigInt huge = BigInt.parse("9999999999999999999999999999999");
BigInt is a completely separate class. It is not "an int that grew" — we have to opt in by explicitly using BigInt.

A BigInt stores its value as an array of digits (typically 32-bit chunks under the hood). Want a number with 1000 digits? BigInt allocates a larger digit array. Every arithmetic operation involves allocations, comparisons, and carry propagation across digits. It is slow, but it is the only way to handle truly massive numbers.

When BigInt is the right tool: cryptography, scientific computation with arbitrary precision, and financial maths where overflow is unacceptable.

When BigInt is overkill: counters, IDs, scores, file sizes, timestamps — anything that fits in 64 bits.

4

The collection problem

Here is where things get interesting. Boxing is not usually a big deal for one number. It becomes a big deal for many numbers.

List<int> data = List.filled(1000000, 0);
A list of one million ints. How is this stored?

The list itself is a contiguous block of slots — one per element. Each slot is either a Smi (value inline) or a reference to a Mint. If our values fit as Smis we are fine, but the list is still holding a million pointer-sized slots, when we may only need a million 4-byte or even 1-byte numbers.

For numeric work — image processing, audio, networking, game physics — this overhead adds up fast.

The solution is typed data.

5

Typed lists — packed and unboxed

Dart's dart:typed_data library is built for the case where we have lots of numbers and we want them stored efficiently.

import 'dart:typed_data';

Int32List numbers = Int32List(1000000);
numbers[0] = 42;
numbers[1] = -100;
The crucial difference: Int32List stores raw 32-bit integers, packed contiguously, with no boxing. A million of them is exactly 4 MB. There are no pointers, no Mint objects, no per-element overhead. The CPU can stream through them at maximum speed because they live next to each other in memory.

List<int> — 8 elements pointer-sized slots, with some values boxed into Mint heap objects 42 ptr 0xA1B… 7 1000 ptr 0xC3D… -3 99 256 Mint 9000000000 Mint 5000000000 ~64 bytes of slots + heap objects, scattered across memory Int32List — 8 elements raw 4-byte cells, packed contiguously, no boxing 42 8400 7 1000 9999 -3 99 256 32 bytes total — all contiguous Half the memory. Cache-friendly. No GC pressure.

Dart provides typed lists for every common numeric width.

Signed integers:
Int8List — one byte per element, range -128 to 127
Int16List — two bytes, range -32,768 to 32,767
Int32List — four bytes
Int64List — eight bytes

Unsigned integers:
Uint8List — one byte, range 0 to 255 (the workhorse for file and network bytes)
Uint16List, Uint32List, Uint64List
Uint8ClampedList — like Uint8List, but values above 255 are clamped to 255 instead of wrapping. Used heavily in image processing.

Floating point:
Float32List, Float64List — we'll meet these properly in the next episode on doubles.

A rough guide to picking one:
• File bytes, network bytes, raw binary protocols → Uint8List
• Image pixel channels → Uint8List or Uint8ClampedList
• 16-bit audio samples → Int16List
• Coordinate buffers for graphics → Float32List
• General numeric work where 64-bit is overkill → pick the smallest type that comfortably fits our range

6

ByteBuffer and ByteData — down to the bytes

Sometimes we don't know in advance what types we'll read out of a chunk of bytes. We might be parsing a binary file format where the first 4 bytes are a magic number, the next 2 bytes are a version, the next 4 bytes are a length, and so on.

For that, Dart gives us two lower-level tools.

ByteBuffer is a raw buffer of bytes. We can't read from it directly — we always go through a view.

ByteData is a view over a ByteBuffer that lets us read or write any numeric type at any byte offset.

ByteData data = ByteData(8);
data.setInt32(0, 42);        // write a 32-bit int at offset 0
data.setFloat32(4, 3.14);    // write a 32-bit float at offset 4

int x = data.getInt32(0);    // read it back
double y = data.getFloat32(4);
ByteData also lets us specify endianness — the order in which the bytes of a multi-byte value are arranged.

data.setInt32(0, 42, Endian.big);     // big-endian (network byte order)
data.setInt32(0, 42, Endian.little);  // little-endian (most CPUs natively)
This matters when reading binary formats. PNG files use big-endian. Most CPUs use little-endian internally. If we get this wrong, we read garbage.

We can also reinterpret the same bytes as a different typed view, with no copying:

Uint8List bytes = Uint8List(16);
ByteBuffer buffer = bytes.buffer;
Int32List asInts = buffer.asInt32List();  // same bytes, viewed as four 32-bit ints
Both views look at the same underlying memory. Writing through one is visible through the other. This is incredibly powerful for performance — and a little dangerous, because we're working very close to the metal.

7

Putting it to work — optimization in practice

Let us make this practical. When should we reach for which representation?

For single values:
• Just use int. Dart will pick Smi or Mint for us.
• Avoid BigInt unless we actually need precision beyond 64 bits.
• In hot loops, try to keep values within Smi range. A counter that stays under a billion runs faster than one that doesn't.

For nullable ints:
int? can force boxing because the slot has to also be able to hold null. Avoid int? in tight loops or large collections. Use a sentinel value (-1, 0, int.minValue) if we can.

For collections of numbers:
• Use typed lists (Int32List, Float64List, etc.) instead of List<int> or List<double> whenever the collection is bigger than a few dozen elements.
• Pick the smallest type that fits our range. Don't use Int64List for byte data.
• For very large collections, the difference can be 4-8× memory and a similar factor in speed.

For binary I/O:
• File bytes, sockets, codecs → Uint8List is the workhorse.
• For parsing structured binary → ByteData with explicit endianness.

For native interop (FFI):
• Typed lists can be passed to C code without copying. The buffer is a contiguous block of memory that native code can read and write directly. Huge for performance when calling native libraries.

A practical example. Say we're loading a 1024×1024 grayscale image — one million pixels, each a value 0-255.

// The wrong way — boxes everything
List<int> pixels = List.filled(1024 * 1024, 0);
// Memory: ~8 MB (pointer per element on 64-bit), plus potential Mint objects

// The right way — packed and unboxed
Uint8List pixels = Uint8List(1024 * 1024);
// Memory: exactly 1 MB
That's an 8× memory saving, plus much faster iteration because the bytes are contiguous and the CPU's cache loves them. For a real image processing pipeline, the difference between these two choices is the difference between snappy and sluggish.

Test your understanding

7 questions

Seven questions covering Smi, Mint, BigInt, boxing, typed lists, and byte buffers. Pick the best answer for each.

Search

Loading search...