When I first started writing Dart code, I treated collections the same way I treated arrays in every other language I’d touched — as dumb containers I loop over with a for loop. It took me a while, and a fair number of messy Flutter widgets, to realize that Dart’s collection system is one of the most thoughtfully designed parts of the language. Once I actually understood how List, Set, Map, and the Iterable contract fit together, my code got shorter, faster, and genuinely easier to read.
This is the guide I wish I’d had when I started. I’m going to walk through everything from the basic syntax to the internal mechanics of how these collections behave in memory, because understanding “why” made me a much better Dart developer than just memorizing “how.”
Table of Contents
- What Collections Actually Are in Dart
- Lists: The Workhorse Collection
- Sets: Uniqueness by Design
- Maps: Key-Value Storage
- The Iterable Contract — Why It Matters
- Iteration Methods: map, where, reduce, fold, and More
- Null Safety in Collections
- Memory Management and Performance
- Real-World and Flutter Use Cases
- Best Practices and Common Mistakes
- Debugging Collection Issues
- FAQs
- Summary and References
1. What Collections Actually Are in Dart
In Dart, every collection type — List, Set, and Map — implements the Iterable interface (well, Map is a bit different, but I’ll get to that). This shared foundation is why so many of the same methods, like map(), where(), and forEach(), work almost identically across all three types.
I like to think of it this way: Iterable is the contract, and List, Set, and Map are three different implementations of “a bunch of things I can walk through in order (or in the case of Map, a bunch of pairs).”
void main() {
List<int> numbers = [1, 2, 3, 4, 5];
Set<String> uniqueNames = {'Ali', 'Sara', 'John'};
Map<String, int> ages = {'Ali': 25, 'Sara': 30};
print(numbers);
print(uniqueNames);
print(ages);
}
Output:
[1, 2, 3, 4, 5]
{Ali, Sara, John}
{Ali: 25, Sara: 30}
Notice the syntax difference: square brackets for List, curly braces for both Set and Map. Dart tells them apart by content — if you write {} with no type annotation, Dart defaults to Map because that’s the more common use case. I got bitten by this early on when I wrote var empty = {}; expecting a Set and got a Map<dynamic, dynamic> instead.
2. Lists: The Workhorse Collection
A List in Dart is an ordered collection, and it’s genuinely the collection I reach for most often. It’s the direct equivalent of an array, but with a much richer API.
Creating Lists
void main() {
// Literal syntax
List<String> fruits = ['Apple', 'Banana', 'Mango'];
// Growable list constructor
List<int> scores = List.filled(3, 0, growable: true);
// Generating a list
List<int> squares = List.generate(5, (index) => index * index);
print(fruits);
print(scores);
print(squares);
}
Output:
[Apple, Banana, Mango]
[0, 0, 0]
[0, 1, 4, 9, 16]
List.generate is one I use constantly when I need to build a list based on a formula or index — it’s much cleaner than looping and calling .add() repeatedly.
Fixed-Length vs Growable Lists
This is a distinction I got wrong more than once early on. List.filled() without growable: true creates a fixed-length list. Try to add to it, and Dart throws an UnsupportedError at runtime, not compile time.
void main() {
var fixedList = List.filled(3, 'x');
try {
fixedList.add('y');
} catch (e) {
print('Error: $e');
}
}
Output:
Error: Unsupported operation: Cannot add to a fixed-length list
This is the internal representation talking: a fixed-length list under the hood is backed by a plain, size-locked array, whereas a growable list wraps that array and reallocates it (typically doubling capacity) whenever it runs out of room. That reallocation is why growable lists have amortized O(1) add() performance rather than a guaranteed O(1) every time.
Common List Operations
void main() {
List<int> numbers = [5, 3, 8, 1, 9];
numbers.sort(); // [1, 3, 5, 8, 9]
numbers.add(10); // [1, 3, 5, 8, 9, 10]
numbers.insert(0, 0); // [0, 1, 3, 5, 8, 9, 10]
numbers.removeAt(2); // removes the value at index 2 (which is 3) -> [0, 1, 5, 8, 9, 10]
print('Modified: $numbers');
print('Contains 8: ${numbers.contains(8)}');
print('Index of 9: ${numbers.indexOf(9)}');
print('First: ${numbers.first}, Last: ${numbers.last}');
}
Output:
Modified: [0, 1, 5, 8, 9, 10]
Contains 8: true
Index of 9: 4
First: 0, Last: 10
A habit that has saved me a lot of confusion: whenever I chain several mutating calls like this, I add an inline comment showing the list’s state after each line. It’s a small thing, but tracing index mutations by hand is exactly the kind of mistake that’s easy to make when reading code too fast.
Spread and Collection-If/For Operators
Dart added some genuinely useful syntax sugar for building collections declaratively, and I use it heavily in Flutter widget trees.
void main() {
List<int> base = [1, 2, 3];
List<int> extended = [0, ...base, 4, 5];
print(extended);
bool includeExtras = true;
List<String> items = [
'core1',
'core2',
if (includeExtras) 'extra1',
if (includeExtras) 'extra2',
];
print(items);
List<int> doubled = [for (var n in base) n * 2];
print(doubled);
}
Output:
[0, 1, 2, 3, 4, 5]
[core1, core2, extra1, extra2]
[2, 4, 6]
3. Sets: Uniqueness by Design
A Set is an unordered collection of unique items. I reach for Set whenever duplicates would be a bug — think tag lists, unique user IDs, or deduplicating search results.
void main() {
Set<int> ids = {1, 2, 3, 3, 2, 1};
print(ids); // Duplicates are silently dropped
ids.add(4);
ids.remove(1);
print(ids);
Set<int> a = {1, 2, 3};
Set<int> b = {2, 3, 4};
print('Union: ${a.union(b)}');
print('Intersection: ${a.intersection(b)}');
print('Difference: ${a.difference(b)}');
}
Output:
{1, 2, 3}
{2, 3, 4}
Union: {1, 2, 3, 4}
Intersection: {2, 3}
Difference: {1}
How Sets Determine Uniqueness
Internally, Dart’s default Set implementation (LinkedHashSet) uses hashing — it calls hashCode and == on your objects to determine equality. This matters a lot when you’re storing custom objects in a Set.
class Point {
final int x, y;
Point(this.x, this.y);
@override
bool operator ==(Object other) =>
other is Point && other.x == x && other.y == y;
@override
int get hashCode => Object.hash(x, y);
}
void main() {
Set<Point> points = {Point(1, 2), Point(1, 2), Point(3, 4)};
print(points.length);
}
Output:
2
I learned this the hard way — without overriding == and hashCode, every Point instance is considered unique by reference, and my “deduplication” logic silently did nothing. If you’re putting custom classes in a Set (or using them as Map keys), always override both == and hashCode together, and never one without the other — Dart’s contract requires that equal objects produce equal hash codes.
LinkedHashSet vs HashSet
By default, {} and Set() create a LinkedHashSet, which preserves insertion order during iteration. If you don’t care about order and want marginally better performance for very large sets, you can use HashSet from dart:collection.
import 'dart:collection';
void main() {
var linked = LinkedHashSet<int>.from([3, 1, 2]);
var hash = HashSet<int>.from([3, 1, 2]);
print(linked); // Preserves insertion order: {3, 1, 2}
print(hash); // Order not guaranteed
}
4. Maps: Key-Value Storage
A Map associates unique keys with values. It’s the collection I use for anything resembling a lookup table, configuration object, or JSON-like structure.
void main() {
Map<String, dynamic> user = {
'name': 'Ahsan',
'age': 28,
'isActive': true,
};
print(user['name']);
user['email'] = 'ahsan@example.com';
user.remove('isActive');
print(user);
print(user.containsKey('email'));
print(user.keys);
print(user.values);
}
Output:
Ahsan
{name: Ahsan, age: 28, email: ahsan@example.com}
true
(name, age, email)
(Ahsan, 28, ahsan@example.com)
Safe Access with putIfAbsent and Null-Aware Operators
Accessing a missing key with [] returns null rather than throwing, which is convenient but can hide bugs if you’re not careful with null safety.
void main() {
Map<String, int> inventory = {'apples': 10};
int? bananaCount = inventory['bananas'];
print(bananaCount); // null
inventory.putIfAbsent('bananas', () => 0);
inventory.update('bananas', (value) => value + 5, ifAbsent: () => 5);
print(inventory);
}
Output:
null
{apples: 10, bananas: 5}
putIfAbsent and update are two methods I underused for a long time, but they eliminate a whole class of “check if key exists, then set or increment” boilerplate.
Iterating Maps
void main() {
Map<String, int> scores = {'Ali': 90, 'Sara': 85, 'John': 78};
scores.forEach((key, value) {
print('$key scored $value');
});
for (var entry in scores.entries) {
print('${entry.key} -> ${entry.value}');
}
}
Output:
Ali scored 90
Sara scored 85
John scored 78
Ali -> 90
Sara -> 85
John -> 78
5. The Iterable Contract — Why It Matters
Here’s the concept that changed how I think about Dart collections: Iterable is lazy by default. When you call .map() or .where() on an Iterable, Dart doesn’t immediately compute the result — it returns a new lazy Iterable that computes each element only when it’s actually requested.
void main() {
var numbers = [1, 2, 3, 4, 5];
var lazyMapped = numbers.map((n) {
print('Processing $n');
return n * n;
});
print('Mapped object created, nothing printed yet');
print('Now consuming: ${lazyMapped.toList()}');
}
Output:
Mapped object created, nothing printed yet
Processing 1
Processing 2
Processing 3
Processing 4
Processing 5
Now consuming: [1, 4, 9, 16, 25]
This surprised me the first time I saw it. The print('Processing $n') calls don’t happen until .toList() forces evaluation. This laziness means you can chain multiple operations (map, where, take) without Dart building an intermediate list at every step — it only does the work once, when you finally consume the result.
This matters for performance on large data sets. If you’re chaining five operations over a million-element list, an eager (non-lazy) approach would build five intermediate million-element lists. Dart’s lazy Iterable avoids that entirely by fusing the operations during a single pass.
6. Iteration Methods: map, where, reduce, fold, and More
This is the part of Dart collections I use every single day. Let me go through the essentials with real examples.
map() — Transform Each Element
void main() {
List<int> prices = [100, 200, 300];
List<double> withTax = prices.map((p) => p * 1.1).toList();
print(withTax);
}
Output:
[110.00000000000001, 220.00000000000002, 330.0]
(That floating-point noise is a classic IEEE 754 quirk, not a Dart bug — worth knowing before you panic over it in production.)
where() — Filter Elements
void main() {
List<int> numbers = [1, 2, 3, 4, 5, 6, 7, 8];
var evens = numbers.where((n) => n % 2 == 0).toList();
print(evens);
}
Output:
[2, 4, 6, 8]
reduce() and fold() — Combine Elements
void main() {
List<int> numbers = [1, 2, 3, 4, 5];
int sum = numbers.reduce((a, b) => a + b);
print('Sum: $sum');
int sumWithStart = numbers.fold(100, (previous, element) => previous + element);
print('Sum with starting 100: $sumWithStart');
}
Output:
Sum: 15
Sum with starting 100: 115
The difference between reduce and fold is one I explain to junior developers a lot: reduce uses the first element as the initial accumulator and throws a StateError on an empty list, while fold takes an explicit initial value and works fine on empty collections.
void main() {
List<int> empty = [];
try {
empty.reduce((a, b) => a + b);
} catch (e) {
print('reduce error: $e');
}
print('fold result: ${empty.fold(0, (p, e) => p + e)}');
}
Output:
reduce error: Bad state: No element
fold result: 0
Other Methods I Use Constantly
void main() {
List<int> numbers = [3, 7, 1, 9, 4];
print(numbers.any((n) => n > 8)); // true
print(numbers.every((n) => n > 0)); // true
print(numbers.take(2)); // (3, 7)
print(numbers.skip(2)); // (1, 9, 4)
print(numbers.firstWhere((n) => n > 5)); // 7
print(numbers.expand((n) => [n, n * 10])); // (3, 30, 7, 70, ...)
}
Output:
true
true
(3, 7)
(1, 9, 4)
7
(3, 30, 7, 70, 1, 10, 9, 90, 4, 40)
expand is underrated — it’s how you flatten nested collections without writing manual nested loops.
7. Null Safety in Collections
Since Dart 2.12, collections interact with null safety in ways that catch real bugs at compile time.
void main() {
List<int> numbers = [1, 2, 3]; // Cannot contain null
// numbers.add(null); // Compile-time error
List<int?> nullableNumbers = [1, null, 3]; // Explicitly nullable
print(nullableNumbers);
int? maybeFirst = nullableNumbers.firstOrNull;
print(maybeFirst);
}
Output:
[1, null, 3]
1
firstOrNull (and lastOrNull) are extension methods from package:collection or newer Dart core additions that save you from StateError crashes when a list might be empty. Before these existed, I’d write:
int? safeFirst(List<int> list) => list.isEmpty ? null : list.first;
Now the built-in accessors handle it directly, which is one less utility function cluttering my codebase.
8. Memory Management and Performance
A few internal details that changed how I write performance-sensitive Dart code:
- List growth: A growable
Listtypically doubles its backing array capacity when it fills up, soadd()is amortized O(1) but occasionally triggers an O(n) copy. - Set and Map lookups: Both are hash-based, giving average O(1) lookup, insertion, and deletion — far better than a
List‘s O(n)contains()check for large collections. - Choosing List vs Set for membership checks: If I’m checking
contains()repeatedly on a large collection, I convert to aSetfirst. The one-time O(n) conversion cost pays for itself quickly.
import 'dart:math';
void main() {
final random = Random();
final bigList = List.generate(100000, (i) => i);
final bigSet = bigList.toSet();
final target = 99999;
final stopwatch1 = Stopwatch()..start();
bigList.contains(target);
stopwatch1.stop();
final stopwatch2 = Stopwatch()..start();
bigSet.contains(target);
stopwatch2.stop();
print('List.contains: ${stopwatch1.elapsedMicroseconds}µs');
print('Set.contains: ${stopwatch2.elapsedMicroseconds}µs');
}
The exact numbers vary by machine, but the pattern holds: Set.contains() is dramatically faster than List.contains() on large collections because it’s a hash lookup instead of a linear scan.
- Immutable collections:
List.unmodifiable()andconstlists don’t copy-on-write; they simply throw if you try to mutate them, which is a cheap safety net for data you never intend to change.
void main() {
final constList = const [1, 2, 3];
final unmodifiable = List.unmodifiable([4, 5, 6]);
try {
constList.add(4);
} catch (e) {
print('const error: $e');
}
try {
unmodifiable.add(7);
} catch (e) {
print('unmodifiable error: $e');
}
}
Output:
const error: Unsupported operation: Cannot add to an unmodifiable list
unmodifiable error: Unsupported operation: Cannot add to an unmodifiable list
9. Real-World and Flutter Use Cases
In Flutter, I use collections everywhere — but a few patterns come up constantly:
Building Widget Lists from Data
List<Widget> buildTiles(List<String> names) {
return names.map((name) => ListTile(title: Text(name))).toList();
}
Filtering a Search List
List<String> filterSearch(List<String> items, String query) {
return items.where((item) => item.toLowerCase().contains(query.toLowerCase())).toList();
}
Grouping Data by Key
Map<String, List<String>> groupByFirstLetter(List<String> words) {
Map<String, List<String>> grouped = {};
for (var word in words) {
String letter = word[0].toUpperCase();
grouped.putIfAbsent(letter, () => []).add(word);
}
return grouped;
}
void main() {
print(groupByFirstLetter(['apple', 'avocado', 'banana', 'blueberry', 'cherry']));
}
Output:
{A: [apple, avocado], B: [banana, blueberry], C: [cherry]}
I use this grouping pattern for everything from contact lists sorted alphabetically to grouping transactions by date in a finance app.
Deduplicating API Results
List<Map<String, dynamic>> deduplicateById(List<Map<String, dynamic>> items) {
final seen = <dynamic>{};
return items.where((item) => seen.add(item['id'])).toList();
}
Set.add() returns false if the element already existed, which makes this one-liner deduplication trick genuinely elegant once you know it.
10. Best Practices and Common Mistakes
Things I actively try to follow now, mostly because I broke each of these rules at least once:
- Prefer
finalovervarfor collections you won’t reassign. The collection’s contents can still change; only the reference is locked. - Don’t mutate a list while iterating it directly — use
.toList()on the iterable first, or iterate over a copy, or useremoveWhere()instead of manual removal inside a loop.
void main() {
List<int> numbers = [1, 2, 3, 4, 5];
// Wrong: numbers.forEach((n) { if (n % 2 == 0) numbers.remove(n); }); // Concurrent modification error
numbers.removeWhere((n) => n % 2 == 0);
print(numbers);
}
Output:
[1, 3, 5]
- Choose the right collection for the job:
Listfor order and duplicates,Setfor uniqueness,Mapfor lookups. - Always override both
==andhashCodewhen using custom objects inSets or asMapkeys. - Use
constcollections for compile-time constant data — Flutter’s widget rebuild performance benefits noticeably fromconstlists in widget trees. - Avoid unnecessary
.toList()calls in chains if you’re just going to iterate once — keep it lazy until you actually need a concrete list.
11. Debugging Collection Issues
A few debugging habits that have saved me hours:
- “Concurrent modification during iteration” errors: This means you modified a collection while a
for-inloop orforEachwas actively iterating it. Fix by iterating a copy (list.toList()) or usingremoveWhere/retainWhere. - Unexpected
nullfrom Map access: Always checkcontainsKey()or use the null-aware??operator when reading from aMap<K, V?>. - Comparing lists with
==returns false unexpectedly:Listequality is reference-based by default. UselistEquals()frompackage:collectionor Flutter’sfoundation.dartif you need deep equality.
import 'package:collection/collection.dart';
void main() {
List<int> a = [1, 2, 3];
List<int> b = [1, 2, 3];
print(a == b); // false — different references
print(const ListEquality().equals(a, b)); // true — deep comparison
}
12. FAQs
Q: What’s the difference between List and Iterable in Dart? Iterable is the broader interface describing anything that can be walked through in sequence. List is a concrete implementation of Iterable that adds indexed access, ordering guarantees, and mutation methods like add() and removeAt().
Q: Are Dart Sets ordered? The default LinkedHashSet preserves insertion order for iteration purposes, but you should never rely on Set order the way you’d rely on List order — its core guarantee is uniqueness, not sequence.
Q: When should I use fold instead of reduce? Use fold whenever the collection might be empty, or when your accumulator type differs from the element type (e.g., folding a List<String> into a single combined length int).
Q: How do I convert a List to a Set and back while preserving order? list.toSet().toList() works if you want to deduplicate while roughly preserving first-seen order, since LinkedHashSet maintains insertion order.
Q: Why does my Map show keys in insertion order and not sorted order? Dart’s default Map implementation is LinkedHashMap, which preserves insertion order, not natural key order. Use SplayTreeMap from dart:collection if you need sorted keys.
13. Summary
Collections in Dart aren’t just data containers — they’re a coherent system built around the shared Iterable contract, with laziness baked in for performance and a rich, chainable method set that replaces most manual loops. Once I internalized that List is for order, Set is for uniqueness, and Map is for lookups, choosing the right tool became automatic. Combine that with proper null safety and an understanding of the underlying hash-based performance characteristics, and you’ve got everything you need to write clean, fast, idiomatic Dart — whether you’re building a command-line tool or a full Flutter app.
References
- Dart Language Tour — Collections: https://dart.dev/language/collections
- Dart API Reference — dart:core Iterable: https://api.dart.dev/stable/dart-core/Iterable-class.html
- Dart API Reference — dart:core List: https://api.dart.dev/stable/dart-core/List-class.html
- Dart API Reference — dart:core Set: https://api.dart.dev/stable/dart-core/Set-class.html
- Dart API Reference — dart:core Map: https://api.dart.dev/stable/dart-core/Map-class.html
- Dart API Reference — dart:collection library: https://api.dart.dev/stable/dart-collection/dart-collection-library.html
- Effective Dart — Usage Guidelines: https://dart.dev/effective-dart/usage
- Flutter Documentation — Widget of the Week and List rendering: https://docs.flutter.dev