Or: the only article about Flutter layout you'll ever need to stop guessing
You're building a card. A nice little book card. Image on top, title, author, maybe a rating. You run it. And then the yellow-black hazard tape appears:
════════ Exception caught by rendering library ═════════════════════════════════
A RenderFlex overflowed by 36 pixels on the bottom.
The relevant error-causing widget was:
Column Column:file:///lib/features/books/presentation/widgets/book_card.dart:56:24
════════════════════════════════════════════════════════════════════════════════36 pixels. Not a crash. Not a missing dependency. Not a null pointer. Just... 36 pixels too many. And you know exactly what you'll do. You'll wrap something in Expanded. Or Flexible. Or SingleChildScrollView. You'll try combinations until the yellow tape goes away. And it will work. And you'll have no idea why it worked.
This is that article. The one where we stop guessing.
The question that Flutter asks every single widget
Before we talk about overflow, we need to talk about the question. Because every layout error in Flutter is an answer to this question gone wrong.
The question is: how big are you?
That's it. That's the entire layout system. A parent asks its child "how big are you?", the child answers, and the parent places it. Done. The entire screen, every frame, 60 times a second, is just this question cascading down the tree and answers bubbling back up.
But here's the catch. The parent doesn't just ask "how big are you?" openly. It asks with constraints. It says: "how big are you, given that you must be between 0 and 400 pixels wide, and between 0 and 800 pixels tall?"
That's what a BoxConstraints is:
BoxConstraints(
minWidth: 0,
maxWidth: 400,
minHeight: 0,
maxHeight: 800,
)Four numbers. That's all the information a widget gets about its environment. Not "you're inside a ListView". Not "there's a Column above you". Not "the screen is 1080 pixels wide". Just four numbers: the minimum and maximum for width and height.
And the widget must answer with a size that fits within those constraints. Must. Not "should". Must.
Constraints go down. Sizes go up. Parents set positions.
Memorize this. Tattoo it if you want. Every Flutter layout problem is a violation of this flow.
Every widget has two jobs
Before we get to the crime scene, there's a second idea that makes the rest of this article click. So far you know that constraints flow down and sizes flow up. True. But there's a detail hiding in that sentence, and it's the detail that separates people who fight Flutter from people who don't.
Every widget, during layout, is doing two things at once:
It decides its own size. The thing we've already talked about — it looks at the constraints it was handed, measures its children if it has any, and picks a number that fits.
It decides what constraints to pass down to its own children. Most of the time nobody thinks about this part, because most widgets just pass the parent's constraints through more or less untouched. But some widgets change them on the way through. Some tighten them. Some loosen them. And a small, critically important group of widgets removes the ceiling entirely.
Think of every widget as a middleman between its parent and its children. Its parent hands it a space budget. The middleman can keep that budget exactly as-is, or shrink it before handing it to the kids, or — and this is the part that snaps people's intuition in half — hand the kids a budget bigger than the one it was given.
Wait. Bigger? How does that even make sense? How can a widget give its children more space than its parent gave it?
It can't, if "give" means "physically reserve those pixels on the screen." But remember: constraints aren't pixels. Constraints are a question — "how big do you want to be, within these limits?" A widget can legally ask its children a different question than the one it was asked. And that's where scrolling comes from.
The widgets that take the roof off
Here's the moment this matters. You build a screen. It has a ListView full of cards. Inside one of those cards, you have a Column. You try to add an Expanded to a child of that Column, and Flutter throws a tantrum:
RenderFlex children have non-zero flex but incoming height constraints are unbounded.Unbounded? But you're inside a ListView. The ListView is on a screen. The screen has a height. How is anything unbounded in a universe with a clearly defined top and bottom?
Because ListView is in the business of lifting ceilings. When the screen says "ListView, you can be at most 800 pixels tall," the ListView accepts that limit for its own size, politely, no argument. But when it turns around to talk to its children, it does not pass 800 down. It passes infinity.
Stop and sit with that for a second, because it sounds like a bug. It's not. It's the whole reason scrolling exists as a concept.
A ListView's entire job is to display content that might be longer than the visible area. If the ListView told its children "the sum of all of you must fit in 800 pixels," then a list of fifty items would be mathematically impossible — you'd hit the ceiling on item number four and every item after would have to be zero tall. A scrollable list can only work if the children are allowed to be, collectively, bigger than the visible region. So the ListView quietly removes the vertical ceiling when it hands constraints to its kids, and then uses a viewport to show one sliding window of that overflowing content at a time.
The architecture of a scrollable screen is not "the root widget has infinite height." It's this:
The scrollable widget has a perfectly normal, bounded size from its own parent. It hands its children unbounded space in exchange. Then it renders a fixed-size peephole into that unbounded space and lets the user scroll the peephole around.
The bound stops at the scrollable widget itself. Below that line, on the scroll axis, the ceiling is gone.
And this is why Expanded detonates inside a Column that lives inside a ListView. Remember what Expanded actually means: "after the fixed children have taken their share, give me what's left." That definition assumes there's a total. It assumes "what's left" is a finite, knowable number. But the total coming in from a ListView is infinity. How do you subtract finite children from infinity? Infinity. How do you split infinity among three Expanded siblings with different flex factors? You can't — any split of infinity is still infinity. Flutter has no way to guess a sensible answer, so it refuses and throws.
The fix, almost always, is to give the offending Column its own bounded parent — typically a SizedBox with an explicit height, or to make the ListView itself bounded differently, or to wrap the thing you wanted to be Expanded with something like a FittedBox or an intrinsic sizing widget instead.
The scrollable family all behaves this way on the axis they scroll:
ListView,GridView,CustomScrollView— vertical by default; they lift the vertical ceilingSingleChildScrollView— lifts the ceiling on whichever axis you set it toPageView— lifts the ceiling on the page axisNestedScrollView,ReorderableListView— same deal, same reasons
UnconstrainedBox is the extreme version — it lifts the ceiling on both axes for its single child, without any scrolling attached. It mostly exists as a specialized tool and as a footgun for the unwary.
The other direction: widgets that install a new ceiling
There's a mirror-image category worth naming. Sometimes a parent hands down unbounded or loose constraints, and a widget in the middle clamps them down before the children see them. A SizedBox(height: 200) sitting inside a Column that's inside a ListView says "I don't care that my grandparent handed me infinity — my child is getting exactly 200 pixels and no more." An AspectRatio(aspectRatio: 16/9) derives a concrete height from whatever width it received and imposes it downward. A ConstrainedBox lets you set custom minimums and maximums explicitly.
These are the widgets you reach for when an unbounded ancestor is causing chaos for something further down the tree. They're the sanity anchors. When you see "unbounded height" errors, the question to ask is usually not "how do I make this child smaller" but "where in the chain can I install a ceiling."
The rule, now more honest
Once you've seen both directions, the catchphrase "constraints go down, sizes go up" is really shorthand for a three-step conversation:
- What constraints does this widget receive from its parent?
- What constraints does this widget pass to its children? (May be the same, tighter, looser, or — for the scrollable family — stripped of the ceiling entirely.)
- What size does this widget report back, given what came in at step 1 and what the children said at step 2?
Almost every layout error you'll ever see is a mismatch between two adjacent numbers in that chain. The RenderFlex overflowed error is a mismatch between steps 1 and 3 — the children summed up to more than the incoming maximum. The unbounded height crash is a mismatch between steps 1 and 2 — something upstream removed the ceiling and a child downstream was counting on one being there. The BoxConstraints forces an infinite height error is the same, the other way around. Once you learn to see both flows happening at once, the error messages stop being cryptic and start being diagnostic.
Keep both flows in your head as we walk through the three types of widgets below. The three types are about step 3 — how widgets decide their own size. But half of the subtle bugs live in step 2, and step 2 is where the scrollable family, and its nemesis Expanded, make their home.
So what actually happened with that Column?
Let's reconstruct the crime scene.
You have a Column. A Column is a RenderFlex under the hood (that's what the error message means — "RenderFlex" is the render object that Column and Row both use). Your Column is sitting inside some parent — maybe a Card, maybe a Container, maybe a SizedBox. That parent said to the Column: "you can be at most 200 pixels tall."
The Column then asks each of its children: "how tall are you?" The image says 120. The title text says 40. The author text says 20. The rating row says 56.
120 + 40 + 20 + 56 = 236.
But the constraint said 200. So the Column has 236 pixels of content in 200 pixels of space. 236 - 200 = 36 pixels of overflow. That's your error. Exactly 36 pixels.
The Column doesn't clip. It doesn't scroll. It doesn't shrink its children. It just... paints them anyway, and then draws the yellow-black hazard tape on the overflow region. Because a Column is not a scrolling widget. It's a "lay them out vertically and hope they fit" widget.
The three types of widgets (by sizing behavior)
Every widget in Flutter falls into one of three categories based on how it answers "how big are you?". Understanding these three categories is the difference between guessing and knowing.
1. "As big as possible" widgets
These widgets look at the constraints they receive and pick the maximum. They want to be as large as their parent allows.
Container(with no child and no explicit size)SizedBox.expand()Expanded(within a flex context)Center(yes — Center takes up all available space, then centers the child inside it)ColoredBoxDecoratedBoxConstrainedBox(by default)
Give a Container() with no child and no size constraints to a parent that says "you can be 0-400 wide, 0-800 tall", and it'll answer: "I'm 400x800." Maximum greed.
2. "As small as possible" widgets
These widgets look at their content and report that size (clamped to constraints). They want to be as small as they can be while still fitting their children.
TextIconRichTextColumn/Row(withmainAxisSize: MainAxisSize.min)WrapUnconstrainedBox
Give a Text("Hello") to a parent that says "you can be 0-400 wide", and it'll measure the text, find it's 45 pixels wide, and answer "I'm 45 wide." Minimum greed.
3. "Exactly this big" widgets
These widgets ignore constraints (within reason) and report a specific size.
SizedBox(width: 100, height: 100)Container(width: 100, height: 100)Image(with explicit dimensions)CustomPaint(with explicit size)
Give a SizedBox(width: 100, height: 100) to any parent, and it'll answer "I'm 100x100." (Unless the constraints force it otherwise — a SizedBox(width: 500) inside a parent that maxes at 200 will still be 200.)
Now let's talk about Column and Row specifically
Column and Row are both Flex widgets. They're the workhorses of Flutter layout. And they're where 80% of overflow errors happen. Here's why.
A Column lays out its children in two passes:
Pass 1: Non-flex children
The Column looks at all children that are NOT wrapped in Expanded or Flexible. It gives each of them unbounded height constraints (or rather, whatever's left). Each child reports its natural height.
This is the dangerous part. "Unbounded" means the child can be as tall as it wants. And some children are very tall. A Text widget with a long string? Could be 300 pixels. An Image with no constraints? Its natural pixel size. A nested Column? The sum of all its children.
Pass 2: Flex children
Whatever space is left after non-flex children gets distributed to Expanded and Flexible children according to their flex factor. An Expanded(flex: 2) gets twice as much remaining space as an Expanded(flex: 1).
The overflow moment
If, after Pass 1, the sum of non-flex children already exceeds the available space — there IS no remaining space for flex children. The Column is in overflow. It's done. The 36-pixel tape goes up.
This is why wrapping a child in Expanded fixes the problem. You're moving it from Pass 1 ("unconstrained, tell me how tall you are") to Pass 2 ("here's exactly how much space you get, fit inside it"). You're taking a greedy child and giving it a budget.
Let's see this in code
Here's the broken book card:
// BROKEN - will overflow if parent height is constrained
Widget build(BuildContext context) {
return Card(
child: SizedBox(
height: 280,
child: Column(
children: [
Image.network(
book.coverUrl,
height: 180,
fit: BoxFit.cover,
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
book.title,
style: Theme.of(context).textTheme.titleMedium,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
book.author,
style: Theme.of(context).textTheme.bodySmall,
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
Icon(Icons.star, size: 16),
Text(' ${book.rating}'),
],
),
),
],
),
),
);
}Let's do the math. SizedBox says 280. The Image is 180. The title with padding? Let's say 40 (16px text + 16px vertical padding). Author with padding? About 28 (12px text + 16px padding). Rating row with padding? About 48 (16px icon + 16px vertical padding). Total: 180 + 40 + 28 + 48 = 296 pixels in 280 pixels of space. Overflow: 16 pixels.
Now if that title wraps to two lines? Add another 16 pixels. Overflow: 32 pixels. Change the font size in the system settings? Even more. This is why the error shows 36 on some devices and 19 on others — text rendering isn't pixel-identical across devices.
Fix 1: Make text flexible
Column(
children: [
Image.network(
book.coverUrl,
height: 180,
fit: BoxFit.cover,
),
Expanded( // <-- THIS
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
book.title,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
// ... rest unchanged
],
)Now the title participates in Pass 2. It gets whatever space remains after the image, author, and rating are laid out. If there's room for two lines, great. If only one, it ellipses. No overflow ever.
Fix 2: Remove the fixed height
Card(
child: Column(
mainAxisSize: MainAxisSize.min, // <-- don't expand, just be as tall as your children
children: [
// same children
],
),
)No SizedBox. No fixed height. The Column is now a "as small as possible" widget. It sums up its children and reports that height. Can't overflow because there's no upper bound to violate.
But wait — if this card is inside a GridView or a ListView, the parent might impose a fixed height anyway. You're back to the same problem. Fix 1 is almost always more robust.
Fix 3: Scroll
SizedBox(
height: 280,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// same children
],
),
),
)This works but it's almost never what you want in a card. A scrolling card is a UX smell. I'm listing it for completeness but if you reach for this, ask yourself if the design needs fixing instead of the code.
The constraint types you'll actually encounter
Here's the part nobody puts in one place. Every layout scenario in Flutter boils down to what constraints the parent passes to the child. Let's catalogue them:
Tight constraints
BoxConstraints(
minWidth: 200, maxWidth: 200,
minHeight: 300, maxHeight: 300,
)min == max. The child has no choice. It WILL be 200x300. This is what SizedBox(width: 200, height: 300) does to its child. This is what Expanded does inside a Column or Row (for the main axis). This is what the screen itself does to your root widget on that axis.
Loose constraints
BoxConstraints(
minWidth: 0, maxWidth: 200,
minHeight: 0, maxHeight: 300,
)min is 0. The child can be anywhere from 0 to the max. This is what Center passes to its child. This is what Align passes. This is what Column passes to its non-flex children for the cross axis (width).
Unbounded constraints
BoxConstraints(
minWidth: 0, maxWidth: double.infinity,
minHeight: 0, maxHeight: double.infinity,
)Max is infinity. The child can be any size it wants. This is what ListView passes to its children on the scroll axis. This is what Column passes to non-flex children on the main axis (height). This is what UnconstrainedBox passes.
Unbounded constraints are where "as big as possible" widgets panic. A Container() with no child inside a ListView? It tries to be as big as possible, gets infinity, and you get:
BoxConstraints forces an infinite height.Different error, same family. Layout constraints gone wrong.
Bounded vs unbounded: the key question
When you see an overflow error, ask: who bounded this widget? Something upstream gave it a maximum height. Then ask: do the children fit inside that bound? If not, either make the bound bigger, make the children smaller, or make some children flexible.
How painting works (and why overflow shows yellow-black stripes)
When a RenderFlex (Column/Row) finishes layout and realizes its children exceed its size, it doesn't crash. It doesn't skip children. It paints everything — including the parts that stick out. Then, in debug mode only, it paints the overflow indicator (the yellow-black striped region) on top.
In release mode, the overflow just gets clipped silently. No stripes, no error. The content is just cut off. This means overflow bugs can ship to production and you'd never know unless you tested on smaller screens.
This is why the error says "caught by rendering library" — it's a paint-time error, not a layout-time crash. Layout succeeded (the children got sizes and positions). It's just that those positions extend past the parent's boundary.
You can explicitly control this behavior:
Column(
clipBehavior: Clip.hardEdge, // clips overflow instead of painting it
children: [...],
)But this is masking the problem, not solving it. The content is still cut off. You just don't see the yellow tape anymore.
The mental model: constraints flow like water
Think of constraints as a pipe system. The screen is the water tower. It pushes constraints downward. Each widget is a pipe fitting — it can narrow the constraints (a SizedBox that restricts max width), redirect them (a Row that splits horizontal space among children), or remove them (a ListView that makes the scroll axis unbounded).
Water (constraints) can only flow down. It never flows up. A child cannot ask its parent to be bigger. It can only work with what it receives.
Sizes are the water pressure readings flowing back up. Each child measures itself and reports back. The parent uses those reports to position children and report its own size upward.
Overflow happens when a pipe fitting (widget) receives a certain amount of water (constraints) but its internal components (children) generate more pressure (size) than the pipe can handle.
The complete "will this layout work?" checklist
Before we get into edge cases, here's the checklist to run through when building any layout:
1. Trace the constraint chain
Starting from the nearest ancestor that has a known size (the screen, a SizedBox, an Expanded), trace what constraints each widget passes down. Can you calculate the exact maxHeight that reaches your Column? If not, you don't understand the layout yet.
2. Sum the non-flex children
Add up the heights (or widths for Row) of all children that are NOT Expanded or Flexible. Include padding and margins. Is that sum less than the maxHeight from step 1?
3. Check for unbounded parents
Is your Column inside a ListView? A SingleChildScrollView? Another Column without Expanded? Then your Column might be getting unbounded constraints on the main axis. An unbounded Column can't overflow (there's no max to exceed), but it also can't use Expanded children (because there's no "remaining space" when space is infinite).
4. Test with DevTools
Flutter DevTools has a Layout Explorer. Open it. Click on any widget. See its constraints, its size, and the constraints it passes to children. This is not optional. This is the debugger for layout.
5. Test with extremes
Set your text scale factor to 2x. Use the smallest supported screen size. Use the longest possible string for every text field. If it doesn't overflow under these conditions, it won't overflow in production.
The special cases that always trip people up
Column inside Column
Column(
children: [
Column( // <-- this inner Column gets unbounded height!
children: [
Text('hello'),
Text('world'),
],
),
],
)The outer Column gives the inner Column unbounded height. The inner Column says "I'm 40 pixels tall" (sum of the two Text widgets). No overflow. But if you put an Expanded inside the inner Column? Boom:
RenderFlex children have non-zero flex but incoming height constraints are unbounded.Because Expanded says "give me the remaining space" but the remaining space is infinity minus 40 = infinity, and you can't give a child infinite pixels.
Fix: wrap the inner Column in Expanded so it gets bounded height from the outer Column.
Row inside Column with long text
Column(
children: [
Row(
children: [
Icon(Icons.book),
Text('A very very very very very very very long title'),
],
),
],
)The Row gives the Text unbounded width. The Text measures at 600 pixels. The Row is only 400 pixels wide. Overflow on the right.
Fix: wrap the Text in Expanded inside the Row:
Row(
children: [
Icon(Icons.book),
Expanded(
child: Text(
'A very very very very very very very long title',
overflow: TextOverflow.ellipsis,
),
),
],
)ListView inside Column
Column(
children: [
Text('Header'),
ListView( // <-- CRASH
children: [...],
),
],
)ListView needs a bounded height (to know how big its viewport is). Column gives it unbounded height. Crash:
Vertical viewport was given unbounded height.Fix: wrap ListView in Expanded:
Column(
children: [
Text('Header'),
Expanded(
child: ListView(
children: [...],
),
),
],
)Or use shrinkWrap: true on the ListView (but this kills virtualization — only use it for short lists).
IntrinsicHeight — the nuclear option
Sometimes you need all children in a Row to be the same height, matching the tallest one. Normal constraints can't do this because constraints flow down, not sideways. IntrinsicHeight breaks the rule — it queries each child's intrinsic height first, then constrains them all to the maximum.
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
CardA(),
CardB(),
CardC(),
],
),
)This works but it's expensive. Two layout passes instead of one. Don't use it inside scrolling lists. But for a one-off "make these three cards the same height" scenario? It's fine.
The Expanded vs Flexible distinction
People use these interchangeably. They're not the same.
Expanded = Flexible(fit: FlexFit.tight)
The child MUST fill all allocated space. If Expanded gets 200 pixels, the child's constraint is: minHeight = 200, maxHeight = 200. Tight. No choice.
Flexible = Flexible(fit: FlexFit.loose)
The child CAN fill up to the allocated space but can be smaller. If Flexible gets 200 pixels, the child's constraint is: minHeight = 0, maxHeight = 200. Loose. The child can be 50 pixels if it only needs 50.
When to use which:
- Use Expanded when you want the child to fill all available space (most common)
- Use Flexible when you want the child to be as small as it needs to be, but no bigger than its share (useful for text that might be short or long)
LayoutBuilder — when you need to know
Sometimes you need to make decisions based on available space. Not "phone vs tablet" media query decisions, but "my parent gave me 300 pixels and I need to choose between a compact and expanded layout" decisions.
LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxHeight < 200) {
return CompactBookCard(book: book);
}
return FullBookCard(book: book);
},
)LayoutBuilder gives you the exact constraints your widget receives. It's the escape hatch for when you can't figure out the constraints by reading the code. But it's also a code smell — if you need LayoutBuilder, it often means the layout structure is too implicit. Try to make constraints explicit first.
FittedBox — the "just make it fit" widget
When you have content that's slightly too big and you just want it scaled down:
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'This text might be too wide',
style: TextStyle(fontSize: 24),
),
)BoxFit.scaleDown will shrink the child if it's too big, but won't enlarge it if it's small. The text stays crisp at 24px if it fits, but scales down smoothly if the parent is too narrow. No overflow.
This is excellent for titles in cards where you want to maintain the font size but can't guarantee the text length.
The actual rules, all in one place
- Constraints go down, sizes go up, parents set positions. This is the law. Everything else is commentary.
- A widget cannot decide its own position. It can decide its size (within constraints). Position is always the parent's call.
- A widget cannot know its position. It doesn't know where it is on screen. It only knows its constraints and its children.
- Overflow happens when children's sizes exceed parent's constraints. Either make children smaller (Expanded, Flexible, maxLines, ellipsis) or make the parent bigger (remove fixed height, use mainAxisSize.min).
- Unbounded constraints happen when a scrollable widget or flex widget removes the max constraint. An Expanded child inside an unbounded parent is a contradiction — there's no "remaining space" to expand into.
- IntrinsicHeight/IntrinsicWidth break the one-pass rule. They add a second pass. Use sparingly.
- ClipBehavior hides overflow, doesn't fix it. The content is still cut off. The user just can't see the yellow stripes.
- Text is the most unpredictable child. Different languages, font sizes, accessibility settings, screen widths — text can be any height. Always plan for text being taller than you expect. Always use maxLines + overflow on constrained text.
Back to book_card.dart:56
So. Your Column overflowed by 36 pixels. Now you know:
- The Column's parent gave it a maximum height (probably from a GridView item extent, or a SizedBox, or an AspectRatio)
- The sum of the Column's children exceeds that height by 36 pixels
- On some devices it's 19 pixels, on others 36 — because text rendering varies by device
The fix? One of these, in order of preference:
- Wrap the most variable child (probably the title) in `Expanded` and add
maxLines+TextOverflow.ellipsis - Remove the fixed height from the parent if the design allows it
- Use `Flexible` instead of `Expanded` if you want the child to shrink but not stretch
- Use `FittedBox(fit: BoxFit.scaleDown)` on the title if you want to keep the full text but allow it to shrink
Don't wrap the Column in SingleChildScrollView. Don't set clipBehavior: Clip.hardEdge. Don't add shrinkWrap: true to anything. Those are band-aids that will break something else later.
Understand the constraint chain. Count the pixels. Fix the actual mismatch.
The yellow-black tape is not a bug. It's Flutter telling you that you haven't completely mastered your layout yet. Once you do, it never comes back as a scarry, unpredictable thing. Enjoy designing beautiful layouts with Flutter!