Collections and Iterables in Dart: Lists, Sets, Maps, and Iteration Methods Guide

Collections and Iterables in Dart

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

  1. What Collections Actually Are in Dart
  2. Lists: The Workhorse Collection
  3. Sets: Uniqueness by Design
  4. Maps: Key-Value Storage
  5. The Iterable Contract — Why It Matters
  6. Iteration Methods: map, where, reduce, fold, and More
  7. Null Safety in Collections
  8. Memory Management and Performance
  9. Real-World and Flutter Use Cases
  10. Best Practices and Common Mistakes
  11. Debugging Collection Issues
  12. FAQs
  13. 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 List typically doubles its backing array capacity when it fills up, so add() 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 a Set first. 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() and const lists 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:

  1. Prefer final over var for collections you won’t reassign. The collection’s contents can still change; only the reference is locked.
  2. Don’t mutate a list while iterating it directly — use .toList() on the iterable first, or iterate over a copy, or use removeWhere() 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]
  1. Choose the right collection for the job: List for order and duplicates, Set for uniqueness, Map for lookups.
  2. Always override both == and hashCode when using custom objects in Sets or as Map keys.
  3. Use const collections for compile-time constant data — Flutter’s widget rebuild performance benefits noticeably from const lists in widget trees.
  4. 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-in loop or forEach was actively iterating it. Fix by iterating a copy (list.toList()) or using removeWhere/retainWhere.
  • Unexpected null from Map access: Always check containsKey() or use the null-aware ?? operator when reading from a Map<K, V?>.
  • Comparing lists with == returns false unexpectedly: List equality is reference-based by default. Use listEquals() from package:collection or Flutter’s foundation.dart if 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
Total
1
Shares

Leave a Reply

Previous Post
Constructors and Factories in Dart

Constructors and Factories in Dart: Named, Default, and Factory Constructors Explained

Next Post
Asynchronous Programming in Dart

Asynchronous Programming in Dart: Futures, Async-Await, and Streams Explained

Related Posts