From f7605395ccb16beb2f2a2cb3b5430c7b1333e51a Mon Sep 17 00:00:00 2001
From: Marcus Gartner <marcus@cockroachlabs.com>
Date: Wed, 29 Jan 2025 15:48:51 -0500
Subject: [PATCH] opt: add `optimizer_min_row_count` session setting

Informs #64570
Informs #130201

Release note (sql change): The `optimizer_min_row_count` session setting
has been added which sets a lower bound on row count estimates for
relational expressions during query planning. A value of zero, which is
the default, indicates no lower bound. Note that if this is set to a
value greater than zero, a row count of zero can still be estimated for
expressions with a cardinality of zero, e.g., for a contradictory
filter. Setting this to a value higher than 0, such as 1, may yield
better query plans in some cases, such as when statistics are frequently
stale and inaccurate.
---
 pkg/sql/exec_util.go                          |   4 +
 .../testdata/logic_test/information_schema    |   1 +
 .../logictest/testdata/logic_test/pg_catalog  |   3 +
 .../logictest/testdata/logic_test/show_source |   1 +
 .../exec/execbuilder/testdata/explain_redact  |  16 +-
 pkg/sql/opt/memo/memo.go                      |   3 +
 pkg/sql/opt/memo/memo_test.go                 |   5 +
 pkg/sql/opt/memo/statistics_builder.go        |  58 ++---
 pkg/sql/opt/memo/statistics_builder_test.go   |   3 +-
 pkg/sql/opt/memo/testdata/memo                |   4 +-
 pkg/sql/opt/props/statistics.go               |  28 ++-
 .../xform/testdata/coster/outside-histogram   | 201 ++++++++++++++++--
 pkg/sql/opt/xform/testdata/physprops/ordering |   6 +-
 pkg/sql/opt/xform/testdata/rules/groupby      |   2 +-
 pkg/sql/opt/xform/testdata/rules/join         |   4 +-
 pkg/sql/opt/xform/testdata/rules/join_order   |  12 +-
 pkg/sql/opt/xform/testdata/rules/select       |   6 +-
 pkg/sql/opt/xform/testdata/rules/set          |   2 +-
 .../local_only_session_data.proto             |   6 +
 pkg/sql/vars.go                               |  24 +++
 20 files changed, 316 insertions(+), 73 deletions(-)

diff --git a/pkg/sql/exec_util.go b/pkg/sql/exec_util.go
index 1272e2bb5a9b..354eae0fdeb4 100644
--- a/pkg/sql/exec_util.go
+++ b/pkg/sql/exec_util.go
@@ -3845,6 +3845,10 @@ func (m *sessionDataMutator) SetOptimizerPreferBoundedCardinality(b bool) {
 	m.data.OptimizerPreferBoundedCardinality = b
 }
 
+func (m *sessionDataMutator) SetOptimizerMinRowCount(val float64) {
+	m.data.OptimizerMinRowCount = val
+}
+
 // Utility functions related to scrubbing sensitive information on SQL Stats.
 
 // quantizeCounts ensures that the Count field in the
diff --git a/pkg/sql/logictest/testdata/logic_test/information_schema b/pkg/sql/logictest/testdata/logic_test/information_schema
index 9e2916722411..b95833d6cd71 100644
--- a/pkg/sql/logictest/testdata/logic_test/information_schema
+++ b/pkg/sql/logictest/testdata/logic_test/information_schema
@@ -6172,6 +6172,7 @@ optimizer_always_use_histograms                            on
 optimizer_apply_full_scan_penalty_to_virtual_tables        off
 optimizer_hoist_uncorrelated_equality_subqueries           on
 optimizer_merge_joins_enabled                              on
+optimizer_min_row_count                                    0
 optimizer_prefer_bounded_cardinality                       off
 optimizer_prove_implication_with_virtual_computed_columns  off
 optimizer_push_limit_into_project_filtered_scan            off
diff --git a/pkg/sql/logictest/testdata/logic_test/pg_catalog b/pkg/sql/logictest/testdata/logic_test/pg_catalog
index 9b95a84c7061..ced3b758dcd8 100644
--- a/pkg/sql/logictest/testdata/logic_test/pg_catalog
+++ b/pkg/sql/logictest/testdata/logic_test/pg_catalog
@@ -2932,6 +2932,7 @@ optimizer_always_use_histograms                            on                  N
 optimizer_apply_full_scan_penalty_to_virtual_tables        off                 NULL      NULL        NULL        string
 optimizer_hoist_uncorrelated_equality_subqueries           on                  NULL      NULL        NULL        string
 optimizer_merge_joins_enabled                              on                  NULL      NULL        NULL        string
+optimizer_min_row_count                                    0                   NULL      NULL        NULL        string
 optimizer_prefer_bounded_cardinality                       off                 NULL      NULL        NULL        string
 optimizer_prove_implication_with_virtual_computed_columns  off                 NULL      NULL        NULL        string
 optimizer_push_limit_into_project_filtered_scan            off                 NULL      NULL        NULL        string
@@ -3123,6 +3124,7 @@ optimizer_always_use_histograms                            on                  N
 optimizer_apply_full_scan_penalty_to_virtual_tables        off                 NULL  user     NULL      off                 off
 optimizer_hoist_uncorrelated_equality_subqueries           on                  NULL  user     NULL      on                  on
 optimizer_merge_joins_enabled                              on                  NULL  user     NULL      on                  on
+optimizer_min_row_count                                    0                   NULL  user     NULL      0                   0
 optimizer_prefer_bounded_cardinality                       off                 NULL  user     NULL      off                 off
 optimizer_prove_implication_with_virtual_computed_columns  off                 NULL  user     NULL      off                 off
 optimizer_push_limit_into_project_filtered_scan            off                 NULL  user     NULL      off                 off
@@ -3313,6 +3315,7 @@ optimizer_always_use_histograms                            NULL    NULL     NULL
 optimizer_apply_full_scan_penalty_to_virtual_tables        NULL    NULL     NULL     NULL        NULL
 optimizer_hoist_uncorrelated_equality_subqueries           NULL    NULL     NULL     NULL        NULL
 optimizer_merge_joins_enabled                              NULL    NULL     NULL     NULL        NULL
+optimizer_min_row_count                                    NULL    NULL     NULL     NULL        NULL
 optimizer_prefer_bounded_cardinality                       NULL    NULL     NULL     NULL        NULL
 optimizer_prove_implication_with_virtual_computed_columns  NULL    NULL     NULL     NULL        NULL
 optimizer_push_limit_into_project_filtered_scan            NULL    NULL     NULL     NULL        NULL
diff --git a/pkg/sql/logictest/testdata/logic_test/show_source b/pkg/sql/logictest/testdata/logic_test/show_source
index e98cf912e2ca..10fd89003886 100644
--- a/pkg/sql/logictest/testdata/logic_test/show_source
+++ b/pkg/sql/logictest/testdata/logic_test/show_source
@@ -128,6 +128,7 @@ optimizer_always_use_histograms                            on
 optimizer_apply_full_scan_penalty_to_virtual_tables        off
 optimizer_hoist_uncorrelated_equality_subqueries           on
 optimizer_merge_joins_enabled                              on
+optimizer_min_row_count                                    0
 optimizer_prefer_bounded_cardinality                       off
 optimizer_prove_implication_with_virtual_computed_columns  off
 optimizer_push_limit_into_project_filtered_scan            off
diff --git a/pkg/sql/opt/exec/execbuilder/testdata/explain_redact b/pkg/sql/opt/exec/execbuilder/testdata/explain_redact
index ab8826b2e3f1..af43f3bbb498 100644
--- a/pkg/sql/opt/exec/execbuilder/testdata/explain_redact
+++ b/pkg/sql/opt/exec/execbuilder/testdata/explain_redact
@@ -700,7 +700,7 @@ upsert bc
 query T
 EXPLAIN (OPT, MEMO, REDACT) INSERT INTO bc SELECT a::float + 1 FROM a ON CONFLICT (b) DO UPDATE SET b = bc.b + 100
 ----
-memo (optimized, ~35KB, required=[presentation: info:25] [distribution: test])
+memo (optimized, ~37KB, required=[presentation: info:25] [distribution: test])
  ├── G1: (explain G2 [distribution: test])
  │    └── [presentation: info:19] [distribution: test]
  │         ├── best: (explain G2="[distribution: test]" [distribution: test])
@@ -1140,7 +1140,7 @@ update ab
 query T
 EXPLAIN (OPT, MEMO, REDACT) UPDATE ab SET a = a || 'ab' WHERE a > 'a'
 ----
-memo (optimized, ~14KB, required=[presentation: info:15] [distribution: test])
+memo (optimized, ~15KB, required=[presentation: info:15] [distribution: test])
  ├── G1: (explain G2 [distribution: test])
  │    └── [presentation: info:11] [distribution: test]
  │         ├── best: (explain G2="[distribution: test]" [distribution: test])
@@ -1366,7 +1366,7 @@ update e
 query T
 EXPLAIN (OPT, MEMO, REDACT) UPDATE e SET e = 'eee' WHERE e > 'a'
 ----
-memo (optimized, ~17KB, required=[presentation: info:17] [distribution: test])
+memo (optimized, ~18KB, required=[presentation: info:17] [distribution: test])
  ├── G1: (explain G2 [distribution: test])
  │    └── [presentation: info:13] [distribution: test]
  │         ├── best: (explain G2="[distribution: test]" [distribution: test])
@@ -1690,7 +1690,7 @@ project
 query T
 EXPLAIN (OPT, MEMO, REDACT) SELECT * FROM bc WHERE b >= 1.0 AND b < 2.0
 ----
-memo (optimized, ~12KB, required=[presentation: info:5] [distribution: test])
+memo (optimized, ~13KB, required=[presentation: info:7] [distribution: test])
  ├── G1: (explain G2 [presentation: b:1,c:2] [distribution: test])
  │    └── [presentation: info:5] [distribution: test]
  │         ├── best: (explain G2="[presentation: b:1,c:2] [distribution: test]" [presentation: b:1,c:2] [distribution: test])
@@ -2439,10 +2439,10 @@ project
 query T
 EXPLAIN (OPT, MEMO, REDACT) SELECT * FROM bc JOIN f ON b = f + 1
 ----
-memo (optimized, ~27KB, required=[presentation: info:10] [distribution: test])
- ├── G1: (explain G2 [presentation: b:1,c:2,f:5] [distribution: test])
- │    └── [presentation: info:10] [distribution: test]
- │         ├── best: (explain G2="[presentation: b:1,c:2,f:5] [distribution: test]" [presentation: b:1,c:2,f:5] [distribution: test])
+memo (optimized, ~29KB, required=[presentation: info:14] [distribution: test])
+ ├── G1: (explain G2 [presentation: b:1,c:2,f:7] [distribution: test])
+ │    └── [presentation: info:14] [distribution: test]
+ │         ├── best: (explain G2="[presentation: b:1,c:2,f:7] [distribution: test]" [presentation: b:1,c:2,f:7] [distribution: test])
  │         └── cost: 2247.29
  ├── G2: (project G3 G4 b c f)
  │    ├── [presentation: b:1,c:2,f:5] [distribution: test]
diff --git a/pkg/sql/opt/memo/memo.go b/pkg/sql/opt/memo/memo.go
index fda518da8599..b398a52c2814 100644
--- a/pkg/sql/opt/memo/memo.go
+++ b/pkg/sql/opt/memo/memo.go
@@ -197,6 +197,7 @@ type Memo struct {
 	pushLimitIntoProjectFilteredScan           bool
 	legacyVarcharTyping                        bool
 	preferBoundedCardinality                   bool
+	minRowCount                                float64
 
 	// txnIsoLevel is the isolation level under which the plan was created. This
 	// affects the planning of some locking operations, so it must be included in
@@ -285,6 +286,7 @@ func (m *Memo) Init(ctx context.Context, evalCtx *eval.Context) {
 		pushLimitIntoProjectFilteredScan:           evalCtx.SessionData().OptimizerPushLimitIntoProjectFilteredScan,
 		legacyVarcharTyping:                        evalCtx.SessionData().LegacyVarcharTyping,
 		preferBoundedCardinality:                   evalCtx.SessionData().OptimizerPreferBoundedCardinality,
+		minRowCount:                                evalCtx.SessionData().OptimizerMinRowCount,
 		txnIsoLevel:                                evalCtx.TxnIsoLevel,
 	}
 	m.metadata.Init()
@@ -451,6 +453,7 @@ func (m *Memo) IsStale(
 		m.pushLimitIntoProjectFilteredScan != evalCtx.SessionData().OptimizerPushLimitIntoProjectFilteredScan ||
 		m.legacyVarcharTyping != evalCtx.SessionData().LegacyVarcharTyping ||
 		m.preferBoundedCardinality != evalCtx.SessionData().OptimizerPreferBoundedCardinality ||
+		m.minRowCount != evalCtx.SessionData().OptimizerMinRowCount ||
 		m.txnIsoLevel != evalCtx.TxnIsoLevel {
 		return true, nil
 	}
diff --git a/pkg/sql/opt/memo/memo_test.go b/pkg/sql/opt/memo/memo_test.go
index 65f8eafe1dd0..aaa931322987 100644
--- a/pkg/sql/opt/memo/memo_test.go
+++ b/pkg/sql/opt/memo/memo_test.go
@@ -513,6 +513,11 @@ func TestMemoIsStale(t *testing.T) {
 	evalCtx.SessionData().OptimizerPreferBoundedCardinality = false
 	notStale()
 
+	evalCtx.SessionData().OptimizerMinRowCount = 1.0
+	stale()
+	evalCtx.SessionData().OptimizerMinRowCount = 0
+	notStale()
+
 	// User no longer has access to view.
 	catalog.View(tree.NewTableNameWithSchema("t", catconstants.PublicSchemaName, "abcview")).Revoked = true
 	_, err = o.Memo().IsStale(ctx, &evalCtx, catalog)
diff --git a/pkg/sql/opt/memo/statistics_builder.go b/pkg/sql/opt/memo/statistics_builder.go
index 0107397811da..f05a7c1c37b8 100644
--- a/pkg/sql/opt/memo/statistics_builder.go
+++ b/pkg/sql/opt/memo/statistics_builder.go
@@ -228,18 +228,20 @@ const (
 //
 // See props/statistics.go for more details.
 type statisticsBuilder struct {
-	ctx     context.Context
-	evalCtx *eval.Context
-	md      *opt.Metadata
+	ctx         context.Context
+	evalCtx     *eval.Context
+	md          *opt.Metadata
+	minRowCount float64
 }
 
 func (sb *statisticsBuilder) init(ctx context.Context, evalCtx *eval.Context, md *opt.Metadata) {
 	// This initialization pattern ensures that fields are not unwittingly
 	// reused. Field reuse must be explicit.
 	*sb = statisticsBuilder{
-		ctx:     ctx,
-		evalCtx: evalCtx,
-		md:      md,
+		ctx:         ctx,
+		evalCtx:     evalCtx,
+		md:          md,
+		minRowCount: evalCtx.SessionData().OptimizerMinRowCount,
 	}
 }
 
@@ -822,7 +824,7 @@ func (sb *statisticsBuilder) colAvgSize(tabID opt.TableID, col opt.ColumnID) uin
 
 func (sb *statisticsBuilder) buildScan(scan *ScanExpr, relProps *props.Relational) {
 	s := relProps.Statistics()
-	if zeroCardinality := s.Init(relProps); zeroCardinality {
+	if zeroCardinality := s.Init(relProps, sb.minRowCount); zeroCardinality {
 		// Short cut if cardinality is 0.
 		return
 	}
@@ -1071,7 +1073,7 @@ func (sb *statisticsBuilder) colStatScan(colSet opt.ColSet, scan *ScanExpr) *pro
 
 func (sb *statisticsBuilder) buildSelect(sel *SelectExpr, relProps *props.Relational) {
 	s := relProps.Statistics()
-	if zeroCardinality := s.Init(relProps); zeroCardinality {
+	if zeroCardinality := s.Init(relProps, sb.minRowCount); zeroCardinality {
 		// Short cut if cardinality is 0.
 		return
 	}
@@ -1112,7 +1114,7 @@ func (sb *statisticsBuilder) colStatSelect(
 
 func (sb *statisticsBuilder) buildProject(prj *ProjectExpr, relProps *props.Relational) {
 	s := relProps.Statistics()
-	if zeroCardinality := s.Init(relProps); zeroCardinality {
+	if zeroCardinality := s.Init(relProps, sb.minRowCount); zeroCardinality {
 		// Short cut if cardinality is 0.
 		return
 	}
@@ -1214,7 +1216,7 @@ func (sb *statisticsBuilder) buildInvertedFilter(
 	invFilter *InvertedFilterExpr, relProps *props.Relational,
 ) {
 	s := relProps.Statistics()
-	if zeroCardinality := s.Init(relProps); zeroCardinality {
+	if zeroCardinality := s.Init(relProps, sb.minRowCount); zeroCardinality {
 		// Short cut if cardinality is 0.
 		return
 	}
@@ -1275,7 +1277,7 @@ func (sb *statisticsBuilder) buildJoin(
 	}
 
 	s := relProps.Statistics()
-	if zeroCardinality := s.Init(relProps); zeroCardinality {
+	if zeroCardinality := s.Init(relProps, sb.minRowCount); zeroCardinality {
 		// Short cut if cardinality is 0.
 		return
 	}
@@ -1806,7 +1808,7 @@ func (sb *statisticsBuilder) colStatFromJoinRight(
 
 func (sb *statisticsBuilder) buildIndexJoin(indexJoin *IndexJoinExpr, relProps *props.Relational) {
 	s := relProps.Statistics()
-	if zeroCardinality := s.Init(relProps); zeroCardinality {
+	if zeroCardinality := s.Init(relProps, sb.minRowCount); zeroCardinality {
 		// Short cut if cardinality is 0.
 		return
 	}
@@ -1887,7 +1889,7 @@ func (sb *statisticsBuilder) buildZigzagJoin(
 	zigzag *ZigzagJoinExpr, relProps *props.Relational, h *joinPropsHelper,
 ) {
 	s := relProps.Statistics()
-	if zeroCardinality := s.Init(relProps); zeroCardinality {
+	if zeroCardinality := s.Init(relProps, sb.minRowCount); zeroCardinality {
 		// Short cut if cardinality is 0.
 		return
 	}
@@ -1988,7 +1990,7 @@ func (sb *statisticsBuilder) buildZigzagJoin(
 
 func (sb *statisticsBuilder) buildGroupBy(groupNode RelExpr, relProps *props.Relational) {
 	s := relProps.Statistics()
-	if zeroCardinality := s.Init(relProps); zeroCardinality {
+	if zeroCardinality := s.Init(relProps, sb.minRowCount); zeroCardinality {
 		// Short cut if cardinality is 0.
 		return
 	}
@@ -2100,7 +2102,7 @@ func (sb *statisticsBuilder) colStatGroupBy(
 
 func (sb *statisticsBuilder) buildSetNode(setNode RelExpr, relProps *props.Relational) {
 	s := relProps.Statistics()
-	if zeroCardinality := s.Init(relProps); zeroCardinality {
+	if zeroCardinality := s.Init(relProps, sb.minRowCount); zeroCardinality {
 		// Short cut if cardinality is 0.
 		return
 	}
@@ -2205,7 +2207,7 @@ func (sb *statisticsBuilder) colStatSetNodeImpl(
 // buildValues builds the statistics for a VALUES expression.
 func (sb *statisticsBuilder) buildValues(values ValuesContainer, relProps *props.Relational) {
 	s := relProps.Statistics()
-	if zeroCardinality := s.Init(relProps); zeroCardinality {
+	if zeroCardinality := s.Init(relProps, sb.minRowCount); zeroCardinality {
 		// Short cut if cardinality is 0.
 		return
 	}
@@ -2310,7 +2312,7 @@ func (sb *statisticsBuilder) colStatLiteralValues(
 
 func (sb *statisticsBuilder) buildLimit(limit *LimitExpr, relProps *props.Relational) {
 	s := relProps.Statistics()
-	if zeroCardinality := s.Init(relProps); zeroCardinality {
+	if zeroCardinality := s.Init(relProps, sb.minRowCount); zeroCardinality {
 		// Short cut if cardinality is 0.
 		return
 	}
@@ -2353,7 +2355,7 @@ func (sb *statisticsBuilder) colStatLimit(
 
 func (sb *statisticsBuilder) buildTopK(topK *TopKExpr, relProps *props.Relational) {
 	s := relProps.Statistics()
-	if zeroCardinality := s.Init(relProps); zeroCardinality {
+	if zeroCardinality := s.Init(relProps, sb.minRowCount); zeroCardinality {
 		// Short cut if cardinality is 0.
 		return
 	}
@@ -2395,7 +2397,7 @@ func (sb *statisticsBuilder) colStatTopK(colSet opt.ColSet, topK *TopKExpr) *pro
 
 func (sb *statisticsBuilder) buildOffset(offset *OffsetExpr, relProps *props.Relational) {
 	s := relProps.Statistics()
-	if zeroCardinality := s.Init(relProps); zeroCardinality {
+	if zeroCardinality := s.Init(relProps, sb.minRowCount); zeroCardinality {
 		// Short cut if cardinality is 0.
 		return
 	}
@@ -2448,7 +2450,7 @@ func (sb *statisticsBuilder) colStatOffset(
 
 func (sb *statisticsBuilder) buildMax1Row(max1Row *Max1RowExpr, relProps *props.Relational) {
 	s := relProps.Statistics()
-	if zeroCardinality := s.Init(relProps); zeroCardinality {
+	if zeroCardinality := s.Init(relProps, sb.minRowCount); zeroCardinality {
 		// Short cut if cardinality is 0.
 		return
 	}
@@ -2478,7 +2480,7 @@ func (sb *statisticsBuilder) colStatMax1Row(
 
 func (sb *statisticsBuilder) buildOrdinality(ord *OrdinalityExpr, relProps *props.Relational) {
 	s := relProps.Statistics()
-	if zeroCardinality := s.Init(relProps); zeroCardinality {
+	if zeroCardinality := s.Init(relProps, sb.minRowCount); zeroCardinality {
 		// Short cut if cardinality is 0.
 		return
 	}
@@ -2523,7 +2525,7 @@ func (sb *statisticsBuilder) colStatOrdinality(
 
 func (sb *statisticsBuilder) buildWindow(window *WindowExpr, relProps *props.Relational) {
 	s := relProps.Statistics()
-	if zeroCardinality := s.Init(relProps); zeroCardinality {
+	if zeroCardinality := s.Init(relProps, sb.minRowCount); zeroCardinality {
 		// Short cut if cardinality is 0.
 		return
 	}
@@ -2591,7 +2593,7 @@ func (sb *statisticsBuilder) buildProjectSet(
 	projectSet *ProjectSetExpr, relProps *props.Relational,
 ) {
 	s := relProps.Statistics()
-	if zeroCardinality := s.Init(relProps); zeroCardinality {
+	if zeroCardinality := s.Init(relProps, sb.minRowCount); zeroCardinality {
 		// Short cut if cardinality is 0.
 		return
 	}
@@ -2724,7 +2726,7 @@ func (sb *statisticsBuilder) buildWithScan(
 	withScan *WithScanExpr, relProps, bindingProps *props.Relational,
 ) {
 	s := relProps.Statistics()
-	if zeroCardinality := s.Init(relProps); zeroCardinality {
+	if zeroCardinality := s.Init(relProps, sb.minRowCount); zeroCardinality {
 		// Short cut if cardinality is 0.
 		return
 	}
@@ -2766,7 +2768,7 @@ func (sb *statisticsBuilder) colStatWithScan(
 
 func (sb *statisticsBuilder) buildMutation(mutation RelExpr, relProps *props.Relational) {
 	s := relProps.Statistics()
-	if zeroCardinality := s.Init(relProps); zeroCardinality {
+	if zeroCardinality := s.Init(relProps, sb.minRowCount); zeroCardinality {
 		// Short cut if cardinality is 0.
 		return
 	}
@@ -2804,7 +2806,7 @@ func (sb *statisticsBuilder) colStatMutation(
 
 func (sb *statisticsBuilder) buildLock(lock *LockExpr, relProps *props.Relational) {
 	s := relProps.Statistics()
-	if zeroCardinality := s.Init(relProps); zeroCardinality {
+	if zeroCardinality := s.Init(relProps, sb.minRowCount); zeroCardinality {
 		// Short cut if cardinality is 0.
 		return
 	}
@@ -2836,7 +2838,7 @@ func (sb *statisticsBuilder) colStatLock(colSet opt.ColSet, lock *LockExpr) *pro
 
 func (sb *statisticsBuilder) buildBarrier(barrier *BarrierExpr, relProps *props.Relational) {
 	s := relProps.Statistics()
-	if zeroCardinality := s.Init(relProps); zeroCardinality {
+	if zeroCardinality := s.Init(relProps, sb.minRowCount); zeroCardinality {
 		// Short cut if cardinality is 0.
 		return
 	}
@@ -2870,7 +2872,7 @@ func (sb *statisticsBuilder) colStatBarrier(
 
 func (sb *statisticsBuilder) buildCall(call *CallExpr, relProps *props.Relational) {
 	s := relProps.Statistics()
-	if zeroCardinality := s.Init(relProps); zeroCardinality {
+	if zeroCardinality := s.Init(relProps, sb.minRowCount); zeroCardinality {
 		// Short-cut if cardinality is 0.
 		return
 	}
diff --git a/pkg/sql/opt/memo/statistics_builder_test.go b/pkg/sql/opt/memo/statistics_builder_test.go
index 49ec4fb4c4c3..f34db7d8d2f3 100644
--- a/pkg/sql/opt/memo/statistics_builder_test.go
+++ b/pkg/sql/opt/memo/statistics_builder_test.go
@@ -111,7 +111,8 @@ func TestGetStatsFromConstraint(t *testing.T) {
 		relProps := &props.Relational{Cardinality: props.AnyCardinality}
 		relProps.NotNullCols = cs.ExtractNotNullCols(&evalCtx)
 		s := relProps.Statistics()
-		s.Init(relProps)
+		const minRowCount = 0
+		s.Init(relProps, minRowCount)
 
 		// Calculate distinct counts.
 		sb.applyConstraintSet(cs, true /* tight */, sel, relProps, relProps.Statistics())
diff --git a/pkg/sql/opt/memo/testdata/memo b/pkg/sql/opt/memo/testdata/memo
index a27ba6177385..87c326726658 100644
--- a/pkg/sql/opt/memo/testdata/memo
+++ b/pkg/sql/opt/memo/testdata/memo
@@ -317,7 +317,7 @@ memo (optimized, ~6KB, required=[presentation: array_agg:6])
 memo
 SELECT array_agg(x) FROM (SELECT * FROM a) GROUP BY y
 ----
-memo (optimized, ~7KB, required=[presentation: array_agg:6])
+memo (optimized, ~8KB, required=[presentation: array_agg:6])
  ├── G1: (project G2 G3 array_agg)
  │    └── [presentation: array_agg:6]
  │         ├── best: (project G2 G3 array_agg)
@@ -373,7 +373,7 @@ memo (optimized, ~6KB, required=[presentation: array_cat_agg:6])
 memo
 SELECT array_cat_agg(arr) FROM (SELECT * FROM a) GROUP BY y
 ----
-memo (optimized, ~7KB, required=[presentation: array_cat_agg:6])
+memo (optimized, ~8KB, required=[presentation: array_cat_agg:6])
  ├── G1: (project G2 G3 array_cat_agg)
  │    └── [presentation: array_cat_agg:6]
  │         ├── best: (project G2 G3 array_cat_agg)
diff --git a/pkg/sql/opt/props/statistics.go b/pkg/sql/opt/props/statistics.go
index a8628e279ca8..85abc2c7678e 100644
--- a/pkg/sql/opt/props/statistics.go
+++ b/pkg/sql/opt/props/statistics.go
@@ -54,6 +54,10 @@ type Statistics struct {
 	// expressions with Cardinality.Max > 0, RowCount will be >= epsilon.
 	RowCount float64
 
+	// minRowCount, if greater than zero, limits the lower bound of RowCount
+	// when it is updated by ApplySelectivity. See Init for more details.
+	minRowCount float64
+
 	// VirtualCols is the set of virtual computed columns produced by our input
 	// that we have statistics on. Any of these could appear in ColStats. This set
 	// is maintained separately from OutputCols to allow lookup of statistics on
@@ -87,10 +91,19 @@ type Statistics struct {
 }
 
 // Init initializes the data members of Statistics.
-func (s *Statistics) Init(relProps *Relational) (zeroCardinality bool) {
+//
+// minRowCount, if greater than zero, limits the lower bound of RowCount when it
+// is updated by ApplySelectivity. If minRowCount is zero, then there is no
+// lower bound (however, in practice there is some lower bound due to
+// Selectivity being at least epsilon). Note that if minRowCount is non-zero,
+// RowCount can still be zero if the cardinality of the expression is zero,
+// e.g., for a contradictory filter.
+func (s *Statistics) Init(relProps *Relational, minRowCount float64) (zeroCardinality bool) {
 	// This initialization pattern ensures that fields are not unwittingly
 	// reused. Reusing fields must be done explicitly.
-	*s = Statistics{}
+	*s = Statistics{
+		minRowCount: minRowCount,
+	}
 	if relProps.Cardinality.IsZero() {
 		s.RowCount = 0
 		s.Selectivity = ZeroSelectivity
@@ -126,12 +139,15 @@ func (s *Statistics) CopyFrom(other *Statistics) {
 // counts, and histograms.
 func (s *Statistics) ApplySelectivity(selectivity Selectivity) {
 	if selectivity == ZeroSelectivity {
-		s.RowCount = 0
 		s.Selectivity = ZeroSelectivity
-		return
+		s.RowCount = 0
+	} else if r := s.RowCount * selectivity.AsFloat(); r < s.minRowCount {
+		s.Selectivity.Multiply(MakeSelectivityFromFraction(s.minRowCount, s.RowCount))
+		s.RowCount = s.minRowCount
+	} else {
+		s.Selectivity.Multiply(selectivity)
+		s.RowCount = r
 	}
-	s.RowCount *= selectivity.AsFloat()
-	s.Selectivity.Multiply(selectivity)
 }
 
 // UnionWith unions this Statistics object with another Statistics object. It
diff --git a/pkg/sql/opt/xform/testdata/coster/outside-histogram b/pkg/sql/opt/xform/testdata/coster/outside-histogram
index 0acd1fe544f4..698d474b3bb0 100644
--- a/pkg/sql/opt/xform/testdata/coster/outside-histogram
+++ b/pkg/sql/opt/xform/testdata/coster/outside-histogram
@@ -70,7 +70,7 @@ ALTER TABLE t INJECT STATISTICS '[
 # Q1
 # --------------------------------------------------
 
-opt set=(optimizer_prefer_bounded_cardinality=false)
+opt set=(optimizer_prefer_bounded_cardinality=false,optimizer_min_row_count=0)
 SELECT * FROM t WHERE k IN (110, 120, 130, 140) AND i = 500
 ----
 index-join t
@@ -99,7 +99,7 @@ index-join t
       ├── key: (1)
       └── fd: ()-->(2)
 
-opt set=(optimizer_prefer_bounded_cardinality=true)
+opt set=(optimizer_prefer_bounded_cardinality=true,optimizer_min_row_count=0)
 SELECT * FROM t WHERE k IN (110, 120, 130, 140) AND i = 500
 ----
 index-join t
@@ -128,11 +128,41 @@ index-join t
       ├── key: (1)
       └── fd: ()-->(2)
 
+opt set=(optimizer_prefer_bounded_cardinality=false,optimizer_min_row_count=1)
+SELECT * FROM t WHERE k IN (110, 120, 130, 140) AND i = 500
+----
+select
+ ├── columns: k:1!null i:2!null s:3
+ ├── cardinality: [0 - 4]
+ ├── stats: [rows=1, distinct(1)=1, null(1)=0, distinct(2)=1, null(2)=0, distinct(1,2)=1, null(1,2)=0]
+ │   histogram(1)=  0 0.25  0 0.25  0 0.25  0 0.25
+ │                <--- 110 --- 120 --- 130 --- 140
+ │   histogram(2)=
+ ├── cost: 24.21
+ ├── key: (1)
+ ├── fd: ()-->(2), (1)-->(3)
+ ├── scan t
+ │    ├── columns: k:1!null i:2 s:3
+ │    ├── constraint: /1
+ │    │    ├── [/110 - /110]
+ │    │    ├── [/120 - /120]
+ │    │    ├── [/130 - /130]
+ │    │    └── [/140 - /140]
+ │    ├── cardinality: [0 - 4]
+ │    ├── stats: [rows=4, distinct(1)=4, null(1)=0]
+ │    │   histogram(1)=  0   1   0   1   0   1   0   1
+ │    │                <--- 110 --- 120 --- 130 --- 140
+ │    ├── cost: 24.15
+ │    ├── key: (1)
+ │    └── fd: (1)-->(2,3)
+ └── filters
+      └── i:2 = 500 [outer=(2), constraints=(/2: [/500 - /500]; tight), fd=()-->(2)]
+
 # --------------------------------------------------
 # Q2
 # --------------------------------------------------
 
-opt set=(optimizer_prefer_bounded_cardinality=false)
+opt set=(optimizer_prefer_bounded_cardinality=false,optimizer_min_row_count=0)
 SELECT * FROM t WHERE k IN (100, 110, 120, 130) AND i > 500
 ----
 index-join t
@@ -167,7 +197,7 @@ index-join t
       └── filters
            └── k:1 IN (100, 110, 120, 130) [outer=(1), constraints=(/1: [/100 - /100] [/110 - /110] [/120 - /120] [/130 - /130]; tight)]
 
-opt set=(optimizer_prefer_bounded_cardinality=true)
+opt set=(optimizer_prefer_bounded_cardinality=true,optimizer_min_row_count=0)
 SELECT * FROM t WHERE k IN (100, 110, 120, 130) AND i > 500
 ----
 select
@@ -197,11 +227,41 @@ select
  └── filters
       └── i:2 > 500 [outer=(2), constraints=(/2: [/501 - ]; tight)]
 
+opt set=(optimizer_prefer_bounded_cardinality=false,optimizer_min_row_count=1)
+SELECT * FROM t WHERE k IN (100, 110, 120, 130) AND i > 500
+----
+select
+ ├── columns: k:1!null i:2!null s:3
+ ├── cardinality: [0 - 4]
+ ├── stats: [rows=1, distinct(1)=1, null(1)=0, distinct(2)=1, null(2)=0, distinct(1,2)=1, null(1,2)=0]
+ │   histogram(1)=  0 0.25  0 0.25  0 0.25  0 0.25
+ │                <--- 100 --- 110 --- 120 --- 130
+ │   histogram(2)=
+ ├── cost: 24.21
+ ├── key: (1)
+ ├── fd: (1)-->(2,3)
+ ├── scan t
+ │    ├── columns: k:1!null i:2 s:3
+ │    ├── constraint: /1
+ │    │    ├── [/100 - /100]
+ │    │    ├── [/110 - /110]
+ │    │    ├── [/120 - /120]
+ │    │    └── [/130 - /130]
+ │    ├── cardinality: [0 - 4]
+ │    ├── stats: [rows=4, distinct(1)=4, null(1)=0]
+ │    │   histogram(1)=  0   1   0   1   0   1   0   1
+ │    │                <--- 100 --- 110 --- 120 --- 130
+ │    ├── cost: 24.15
+ │    ├── key: (1)
+ │    └── fd: (1)-->(2,3)
+ └── filters
+      └── i:2 > 500 [outer=(2), constraints=(/2: [/501 - ]; tight)]
+
 # --------------------------------------------------
 # Q3
 # --------------------------------------------------
 
-opt set=(optimizer_prefer_bounded_cardinality=false)
+opt set=(optimizer_prefer_bounded_cardinality=false,optimizer_min_row_count=0)
 SELECT * FROM t WHERE k IN (410, 420, 430) AND i > 500
 ----
 index-join t
@@ -234,7 +294,7 @@ index-join t
       └── filters
            └── k:1 IN (410, 420, 430) [outer=(1), constraints=(/1: [/410 - /410] [/420 - /420] [/430 - /430]; tight)]
 
-opt set=(optimizer_prefer_bounded_cardinality=true)
+opt set=(optimizer_prefer_bounded_cardinality=true,optimizer_min_row_count=0)
 SELECT * FROM t WHERE k IN (410, 420, 430) AND i > 500
 ----
 select
@@ -261,11 +321,38 @@ select
  └── filters
       └── i:2 > 500 [outer=(2), constraints=(/2: [/501 - ]; tight)]
 
+opt set=(optimizer_prefer_bounded_cardinality=false,optimizer_min_row_count=1)
+SELECT * FROM t WHERE k IN (410, 420, 430) AND i > 500
+----
+select
+ ├── columns: k:1!null i:2!null s:3
+ ├── cardinality: [0 - 3]
+ ├── stats: [rows=1, distinct(1)=1, null(1)=0, distinct(2)=1, null(2)=0]
+ │   histogram(1)=
+ │   histogram(2)=
+ ├── cost: 19.03
+ ├── key: (1)
+ ├── fd: (1)-->(2,3)
+ ├── scan t
+ │    ├── columns: k:1!null i:2 s:3
+ │    ├── constraint: /1
+ │    │    ├── [/410 - /410]
+ │    │    ├── [/420 - /420]
+ │    │    └── [/430 - /430]
+ │    ├── cardinality: [0 - 3]
+ │    ├── stats: [rows=6e-07, distinct(1)=6e-07, null(1)=0]
+ │    │   histogram(1)=
+ │    ├── cost: 19.01
+ │    ├── key: (1)
+ │    └── fd: (1)-->(2,3)
+ └── filters
+      └── i:2 > 500 [outer=(2), constraints=(/2: [/501 - ]; tight)]
+
 # --------------------------------------------------
 # Q4
 # --------------------------------------------------
 
-opt set=(optimizer_prefer_bounded_cardinality=false)
+opt set=(optimizer_prefer_bounded_cardinality=false,optimizer_min_row_count=0)
 SELECT * FROM t WHERE k IN (100, 110, 120, 130) AND i = 400 AND s < 'apple'
 ----
 select
@@ -311,7 +398,7 @@ select
  └── filters
       └── i:2 = 400 [outer=(2), constraints=(/2: [/400 - /400]; tight), fd=()-->(2)]
 
-opt set=(optimizer_prefer_bounded_cardinality=true)
+opt set=(optimizer_prefer_bounded_cardinality=true,optimizer_min_row_count=0)
 SELECT * FROM t WHERE k IN (100, 110, 120, 130) AND i = 400 AND s < 'apple'
 ----
 select
@@ -344,11 +431,44 @@ select
       ├── i:2 = 400 [outer=(2), constraints=(/2: [/400 - /400]; tight), fd=()-->(2)]
       └── s:3 < 'apple' [outer=(3), constraints=(/3: (/NULL - /'apple'); tight)]
 
+opt set=(optimizer_prefer_bounded_cardinality=false,optimizer_min_row_count=1)
+SELECT * FROM t WHERE k IN (100, 110, 120, 130) AND i = 400 AND s < 'apple'
+----
+select
+ ├── columns: k:1!null i:2!null s:3!null
+ ├── cardinality: [0 - 4]
+ ├── stats: [rows=1, distinct(1)=1, null(1)=0, distinct(2)=1, null(2)=0, distinct(3)=1, null(3)=0, distinct(2,3)=1, null(2,3)=0, distinct(1-3)=1, null(1-3)=0]
+ │   histogram(1)=  0 0.25  0 0.25  0 0.25  0 0.25
+ │                <--- 100 --- 110 --- 120 --- 130
+ │   histogram(2)=  0   1
+ │                <--- 400
+ │   histogram(3)=
+ ├── cost: 24.22
+ ├── key: (1)
+ ├── fd: ()-->(2), (1)-->(3)
+ ├── scan t
+ │    ├── columns: k:1!null i:2 s:3
+ │    ├── constraint: /1
+ │    │    ├── [/100 - /100]
+ │    │    ├── [/110 - /110]
+ │    │    ├── [/120 - /120]
+ │    │    └── [/130 - /130]
+ │    ├── cardinality: [0 - 4]
+ │    ├── stats: [rows=4, distinct(1)=4, null(1)=0]
+ │    │   histogram(1)=  0   1   0   1   0   1   0   1
+ │    │                <--- 100 --- 110 --- 120 --- 130
+ │    ├── cost: 24.15
+ │    ├── key: (1)
+ │    └── fd: (1)-->(2,3)
+ └── filters
+      ├── i:2 = 400 [outer=(2), constraints=(/2: [/400 - /400]; tight), fd=()-->(2)]
+      └── s:3 < 'apple' [outer=(3), constraints=(/3: (/NULL - /'apple'); tight)]
+
 # --------------------------------------------------
 # Q5
 # --------------------------------------------------
 
-opt set=(enable_zigzag_join=false,optimizer_prefer_bounded_cardinality=false)
+opt set=(enable_zigzag_join=false,optimizer_prefer_bounded_cardinality=false,optimizer_min_row_count=0)
 SELECT * FROM t WHERE i = 400 AND s > 'z'
 ----
 select
@@ -377,7 +497,7 @@ select
  └── filters
       └── i:2 = 400 [outer=(2), constraints=(/2: [/400 - /400]; tight), fd=()-->(2)]
 
-opt set=(enable_zigzag_join=false,optimizer_prefer_bounded_cardinality=true)
+opt set=(enable_zigzag_join=false,optimizer_prefer_bounded_cardinality=true,optimizer_min_row_count=0)
 SELECT * FROM t WHERE i = 400 AND s > 'z'
 ----
 select
@@ -409,11 +529,40 @@ select
  └── filters
       └── i:2 = 400 [outer=(2), constraints=(/2: [/400 - /400]; tight), fd=()-->(2)]
 
+opt set=(enable_zigzag_join=false,optimizer_prefer_bounded_cardinality=false,optimizer_min_row_count=1)
+SELECT * FROM t WHERE i = 400 AND s > 'z'
+----
+select
+ ├── columns: k:1!null i:2!null s:3!null
+ ├── stats: [rows=1, distinct(2)=1, null(2)=0, distinct(3)=1, null(3)=0, distinct(2,3)=1, null(2,3)=0]
+ │   histogram(2)=  0   1
+ │                <--- 400
+ │   histogram(3)=
+ ├── cost: 25.1375
+ ├── key: (1)
+ ├── fd: ()-->(2), (1)-->(3)
+ ├── index-join t
+ │    ├── columns: k:1!null i:2 s:3
+ │    ├── stats: [rows=1]
+ │    ├── cost: 25.0975
+ │    ├── key: (1)
+ │    ├── fd: (1)-->(2,3)
+ │    └── scan t@t_s_idx
+ │         ├── columns: k:1!null s:3!null
+ │         ├── constraint: /3/1: [/e'z\x00' - ]
+ │         ├── stats: [rows=1, distinct(3)=1, null(3)=0]
+ │         │   histogram(3)=
+ │         ├── cost: 19.045
+ │         ├── key: (1)
+ │         └── fd: (1)-->(3)
+ └── filters
+      └── i:2 = 400 [outer=(2), constraints=(/2: [/400 - /400]; tight), fd=()-->(2)]
+
 # --------------------------------------------------
 # Q6
 # --------------------------------------------------
 
-opt set=(optimizer_prefer_bounded_cardinality=false)
+opt set=(optimizer_prefer_bounded_cardinality=false,optimizer_min_row_count=0)
 SELECT * FROM t WHERE k = 100 AND i = 500 AND s = 'zzz'
 ----
 select
@@ -448,7 +597,7 @@ select
  └── filters
       └── s:3 = 'zzz' [outer=(3), constraints=(/3: [/'zzz' - /'zzz']; tight), fd=()-->(3)]
 
-opt set=(optimizer_prefer_bounded_cardinality=true)
+opt set=(optimizer_prefer_bounded_cardinality=true,optimizer_min_row_count=0)
 SELECT * FROM t WHERE k = 100 AND i = 500 AND s = 'zzz'
 ----
 select
@@ -482,3 +631,31 @@ select
  │         └── fd: ()-->(1,2)
  └── filters
       └── s:3 = 'zzz' [outer=(3), constraints=(/3: [/'zzz' - /'zzz']; tight), fd=()-->(3)]
+
+opt set=(optimizer_prefer_bounded_cardinality=false,optimizer_min_row_count=1)
+SELECT * FROM t WHERE k = 100 AND i = 500 AND s = 'zzz'
+----
+select
+ ├── columns: k:1!null i:2!null s:3!null
+ ├── cardinality: [0 - 1]
+ ├── stats: [rows=1, distinct(1)=1, null(1)=0, distinct(2)=1, null(2)=0, distinct(3)=1, null(3)=0]
+ │   histogram(1)=  0   1
+ │                <--- 100
+ │   histogram(2)=
+ │   histogram(3)=
+ ├── cost: 9.085
+ ├── key: ()
+ ├── fd: ()-->(1-3)
+ ├── scan t
+ │    ├── columns: k:1!null i:2 s:3
+ │    ├── constraint: /1: [/100 - /100]
+ │    ├── cardinality: [0 - 1]
+ │    ├── stats: [rows=1, distinct(1)=1, null(1)=0]
+ │    │   histogram(1)=  0   1
+ │    │                <--- 100
+ │    ├── cost: 9.045
+ │    ├── key: ()
+ │    └── fd: ()-->(1-3)
+ └── filters
+      ├── i:2 = 500 [outer=(2), constraints=(/2: [/500 - /500]; tight), fd=()-->(2)]
+      └── s:3 = 'zzz' [outer=(3), constraints=(/3: [/'zzz' - /'zzz']; tight), fd=()-->(3)]
diff --git a/pkg/sql/opt/xform/testdata/physprops/ordering b/pkg/sql/opt/xform/testdata/physprops/ordering
index 6bfe3c1a1f4d..e54398ec958e 100644
--- a/pkg/sql/opt/xform/testdata/physprops/ordering
+++ b/pkg/sql/opt/xform/testdata/physprops/ordering
@@ -686,7 +686,7 @@ memo (optimized, ~4KB, required=[presentation: info:7])
 memo
 SELECT y FROM a WITH ORDINALITY ORDER BY ordinality
 ----
-memo (optimized, ~5KB, required=[presentation: y:2] [ordering: +7])
+memo (optimized, ~6KB, required=[presentation: y:2] [ordering: +7])
  ├── G1: (ordinality G2)
  │    ├── [presentation: y:2] [ordering: +7]
  │    │    ├── best: (ordinality G2)
@@ -725,7 +725,7 @@ memo (optimized, ~7KB, required=[presentation: y:2] [ordering: +8])
 memo
 SELECT y FROM a WITH ORDINALITY ORDER BY ordinality, x
 ----
-memo (optimized, ~8KB, required=[presentation: y:2] [ordering: +7])
+memo (optimized, ~9KB, required=[presentation: y:2] [ordering: +7])
  ├── G1: (ordinality G2)
  │    ├── [presentation: y:2] [ordering: +7]
  │    │    ├── best: (ordinality G2)
@@ -779,7 +779,7 @@ memo (optimized, ~7KB, required=[presentation: y:2] [ordering: +7])
 memo
 SELECT y FROM a WITH ORDINALITY ORDER BY ordinality DESC
 ----
-memo (optimized, ~5KB, required=[presentation: y:2] [ordering: -7])
+memo (optimized, ~6KB, required=[presentation: y:2] [ordering: -7])
  ├── G1: (ordinality G2)
  │    ├── [presentation: y:2] [ordering: -7]
  │    │    ├── best: (sort G1)
diff --git a/pkg/sql/opt/xform/testdata/rules/groupby b/pkg/sql/opt/xform/testdata/rules/groupby
index e3ee50826d72..e7ebf694f013 100644
--- a/pkg/sql/opt/xform/testdata/rules/groupby
+++ b/pkg/sql/opt/xform/testdata/rules/groupby
@@ -2298,7 +2298,7 @@ memo (optimized, ~6KB, required=[presentation: u:2,v:3,w:4] [ordering: +4])
 memo
 SELECT (SELECT w FROM kuvw WHERE v=1 AND x=u) FROM xyz ORDER BY x+1, x
 ----
-memo (optimized, ~26KB, required=[presentation: w:12] [ordering: +13,+1])
+memo (optimized, ~28KB, required=[presentation: w:12] [ordering: +13,+1])
  ├── G1: (project G2 G3 x)
  │    ├── [presentation: w:12] [ordering: +13,+1]
  │    │    ├── best: (sort G1)
diff --git a/pkg/sql/opt/xform/testdata/rules/join b/pkg/sql/opt/xform/testdata/rules/join
index ec4362e9bbea..646d423401b6 100644
--- a/pkg/sql/opt/xform/testdata/rules/join
+++ b/pkg/sql/opt/xform/testdata/rules/join
@@ -235,7 +235,7 @@ inner-join (merge)
 memo expect=ReorderJoins
 SELECT * FROM abc, stu, xyz WHERE abc.a=stu.s AND stu.s=xyz.x
 ----
-memo (optimized, ~46KB, required=[presentation: a:1,b:2,c:3,s:7,t:8,u:9,x:12,y:13,z:14])
+memo (optimized, ~48KB, required=[presentation: a:1,b:2,c:3,s:7,t:8,u:9,x:12,y:13,z:14])
  ├── G1: (inner-join G2 G3 G4) (inner-join G3 G2 G4) (inner-join G5 G6 G7) (inner-join G6 G5 G7) (inner-join G8 G9 G7) (inner-join G9 G8 G7) (merge-join G2 G3 G10 inner-join,+1,+7) (merge-join G3 G2 G10 inner-join,+7,+1) (lookup-join G3 G10 abc@ab,keyCols=[7],outCols=(1-3,7-9,12-14)) (merge-join G5 G6 G10 inner-join,+7,+12) (merge-join G6 G5 G10 inner-join,+12,+7) (lookup-join G6 G10 stu,keyCols=[12],outCols=(1-3,7-9,12-14)) (merge-join G8 G9 G10 inner-join,+7,+12) (lookup-join G8 G10 xyz@xy,keyCols=[7],outCols=(1-3,7-9,12-14)) (merge-join G9 G8 G10 inner-join,+12,+7)
  │    └── [presentation: a:1,b:2,c:3,s:7,t:8,u:9,x:12,y:13,z:14]
  │         ├── best: (merge-join G5="[ordering: +7]" G6="[ordering: +(1|12)]" G10 inner-join,+7,+12)
@@ -343,7 +343,7 @@ SELECT *
 FROM stu, abc, xyz, pqr
 WHERE u = a AND a = x AND x = p
 ----
-memo (optimized, ~40KB, required=[presentation: s:1,t:2,u:3,a:6,b:7,c:8,x:12,y:13,z:14,p:18,q:19,r:20,s:21,t:22])
+memo (optimized, ~42KB, required=[presentation: s:1,t:2,u:3,a:6,b:7,c:8,x:12,y:13,z:14,p:18,q:19,r:20,s:21,t:22])
  ├── G1: (inner-join G2 G3 G4) (inner-join G3 G2 G4) (merge-join G2 G3 G5 inner-join,+3,+6) (merge-join G3 G2 G5 inner-join,+6,+3) (lookup-join G3 G5 stu@uts,keyCols=[6],outCols=(1-3,6-8,12-14,18-22))
  │    └── [presentation: s:1,t:2,u:3,a:6,b:7,c:8,x:12,y:13,z:14,p:18,q:19,r:20,s:21,t:22]
  │         ├── best: (merge-join G2="[ordering: +3]" G3="[ordering: +(6|12|18)]" G5 inner-join,+3,+6)
diff --git a/pkg/sql/opt/xform/testdata/rules/join_order b/pkg/sql/opt/xform/testdata/rules/join_order
index 7c36dafebc42..75b2eb8a4bdc 100644
--- a/pkg/sql/opt/xform/testdata/rules/join_order
+++ b/pkg/sql/opt/xform/testdata/rules/join_order
@@ -360,7 +360,7 @@ memo (optimized, ~24KB, required=[presentation: b:1,x:2,c:5,y:6,a:9,b:10,c:11,d:
 memo set=reorder_joins_limit=2
 SELECT * FROM bx, cy, abc WHERE a = 1 AND abc.b = bx.b AND abc.c = cy.c
 ----
-memo (optimized, ~35KB, required=[presentation: b:1,x:2,c:5,y:6,a:9,b:10,c:11,d:12])
+memo (optimized, ~37KB, required=[presentation: b:1,x:2,c:5,y:6,a:9,b:10,c:11,d:12])
  ├── G1: (inner-join G2 G3 G4) (inner-join G3 G2 G4) (inner-join G5 G6 G7) (inner-join G6 G5 G7) (merge-join G2 G3 G8 inner-join,+1,+10) (merge-join G3 G2 G8 inner-join,+10,+1) (lookup-join G3 G8 bx,keyCols=[10],outCols=(1,2,5,6,9-12)) (merge-join G5 G6 G8 inner-join,+5,+11) (merge-join G6 G5 G8 inner-join,+11,+5) (lookup-join G6 G8 cy,keyCols=[11],outCols=(1,2,5,6,9-12))
  │    └── [presentation: b:1,x:2,c:5,y:6,a:9,b:10,c:11,d:12]
  │         ├── best: (lookup-join G3 G8 bx,keyCols=[10],outCols=(1,2,5,6,9-12))
@@ -521,7 +521,7 @@ inner-join (cross)
 memo set=reorder_joins_limit=0
 SELECT * FROM bx, cy, dz, abc WHERE x = y AND y = z AND z = a
 ----
-memo (optimized, ~31KB, required=[presentation: b:1,x:2,c:5,y:6,d:9,z:10,a:13,b:14,c:15,d:16])
+memo (optimized, ~33KB, required=[presentation: b:1,x:2,c:5,y:6,d:9,z:10,a:13,b:14,c:15,d:16])
  ├── G1: (inner-join G2 G3 G4) (merge-join G2 G3 G5 inner-join,+2,+6)
  │    └── [presentation: b:1,x:2,c:5,y:6,d:9,z:10,a:13,b:14,c:15,d:16]
  │         ├── best: (inner-join G2 G3 G4)
@@ -587,7 +587,7 @@ memo (optimized, ~31KB, required=[presentation: b:1,x:2,c:5,y:6,d:9,z:10,a:13,b:
 memo set=reorder_joins_limit=3
 SELECT * FROM bx, cy, dz, abc WHERE x = y AND y = z AND z = a
 ----
-memo (optimized, ~67KB, required=[presentation: b:1,x:2,c:5,y:6,d:9,z:10,a:13,b:14,c:15,d:16])
+memo (optimized, ~69KB, required=[presentation: b:1,x:2,c:5,y:6,d:9,z:10,a:13,b:14,c:15,d:16])
  ├── G1: (inner-join G2 G3 G4) (inner-join G3 G2 G4) (inner-join G5 G6 G7) (inner-join G6 G5 G7) (inner-join G8 G9 G7) (inner-join G9 G8 G7) (inner-join G10 G11 G12) (inner-join G11 G10 G12) (inner-join G13 G14 G12) (inner-join G14 G13 G12) (inner-join G15 G16 G12) (inner-join G16 G15 G12) (inner-join G17 G18 G12) (inner-join G18 G17 G12) (merge-join G3 G2 G19 inner-join,+6,+2) (merge-join G6 G5 G19 inner-join,+10,+6) (merge-join G9 G8 G19 inner-join,+10,+6) (merge-join G11 G10 G19 inner-join,+13,+10) (merge-join G14 G13 G19 inner-join,+13,+10) (merge-join G16 G15 G19 inner-join,+13,+10) (lookup-join G17 G19 abc,keyCols=[10],outCols=(1,2,5,6,9,10,13-16)) (merge-join G18 G17 G19 inner-join,+13,+10)
  │    └── [presentation: b:1,x:2,c:5,y:6,d:9,z:10,a:13,b:14,c:15,d:16]
  │         ├── best: (inner-join G3 G2 G4)
@@ -2774,7 +2774,7 @@ SELECT (
 )
   FROM table80901_1 AS tab_42921;
 ----
-memo (optimized, ~71KB, required=[presentation: ?column?:50])
+memo (optimized, ~74KB, required=[presentation: ?column?:50])
  ├── G1: (project G2 G3)
  │    └── [presentation: ?column?:50]
  │         ├── best: (project G2 G3)
@@ -3547,7 +3547,7 @@ right-join (hash)
 
 # Only 2 joins are considered (instead of 8) when the STRAIGHT hint is present in one join.
 reorderjoins format=hide-all
-SELECT * 
+SELECT *
 FROM straight_join1
 INNER STRAIGHT JOIN straight_join2 ON straight_join1.x = straight_join2.y
 INNER JOIN straight_join3 ON straight_join1.x = straight_join3.z
@@ -3597,7 +3597,7 @@ inner-join (hash)
 
 # No joins are considered when the STRAIGHT hint is present in both joins.
 reorderjoins format=hide-all
-SELECT * 
+SELECT *
 FROM straight_join1
 INNER STRAIGHT JOIN straight_join2 ON straight_join1.x = straight_join2.y
 INNER STRAIGHT JOIN straight_join3 ON straight_join1.x = straight_join3.z
diff --git a/pkg/sql/opt/xform/testdata/rules/select b/pkg/sql/opt/xform/testdata/rules/select
index e11421f3f111..0a2dabf4bf23 100644
--- a/pkg/sql/opt/xform/testdata/rules/select
+++ b/pkg/sql/opt/xform/testdata/rules/select
@@ -281,7 +281,7 @@ CREATE INDEX idx2 ON p (s) WHERE i > 0
 memo expect=GeneratePartialIndexScans
 SELECT * FROM p WHERE i > 0 AND s = 'foo'
 ----
-memo (optimized, ~19KB, required=[presentation: k:1,i:2,f:3,s:4,b:5])
+memo (optimized, ~20KB, required=[presentation: k:1,i:2,f:3,s:4,b:5])
  ├── G1: (select G2 G3) (index-join G4 p,cols=(1-5)) (index-join G5 p,cols=(1-5)) (index-join G6 p,cols=(1-5)) (index-join G7 p,cols=(1-5))
  │    └── [presentation: k:1,i:2,f:3,s:4,b:5]
  │         ├── best: (index-join G4 p,cols=(1-5))
@@ -1054,7 +1054,7 @@ select
 memo
 SELECT * FROM b WHERE v >= 1 AND v <= 10 AND k+u = 1 AND k > 5
 ----
-memo (optimized, ~12KB, required=[presentation: k:1,u:2,v:3,j:4])
+memo (optimized, ~13KB, required=[presentation: k:1,u:2,v:3,j:4])
  ├── G1: (select G2 G3) (select G4 G5) (select G6 G7)
  │    └── [presentation: k:1,u:2,v:3,j:4]
  │         ├── best: (select G6 G7)
@@ -11686,7 +11686,7 @@ JOIN t61795 AS t2 ON t1.c = t1.b AND t1.b = t2.b
 WHERE t1.a = 10 OR t2.b != abs(t2.b)
 ORDER BY t1.b ASC
 ----
-memo (optimized, ~36KB, required=[presentation: a:1] [ordering: +2])
+memo (optimized, ~38KB, required=[presentation: a:1] [ordering: +2])
  ├── G1: (project G2 G3 a b)
  │    ├── [presentation: a:1] [ordering: +2]
  │    │    ├── best: (sort G1)
diff --git a/pkg/sql/opt/xform/testdata/rules/set b/pkg/sql/opt/xform/testdata/rules/set
index e5e1f3d10206..6a90625be6fa 100644
--- a/pkg/sql/opt/xform/testdata/rules/set
+++ b/pkg/sql/opt/xform/testdata/rules/set
@@ -263,7 +263,7 @@ memo (optimized, ~12KB, required=[presentation: k:1,u:2,v:3,w:4])
 memo expect-not=GenerateStreamingSetOp
 SELECT * FROM kuvw UNION ALL SELECT * FROM kuvw
 ----
-memo (optimized, ~10KB, required=[presentation: k:13,u:14,v:15,w:16])
+memo (optimized, ~11KB, required=[presentation: k:13,u:14,v:15,w:16])
  ├── G1: (union-all G2 G3)
  │    └── [presentation: k:13,u:14,v:15,w:16]
  │         ├── best: (union-all G2 G3)
diff --git a/pkg/sql/sessiondatapb/local_only_session_data.proto b/pkg/sql/sessiondatapb/local_only_session_data.proto
index 955785eea1fd..8d13c65ec8d8 100644
--- a/pkg/sql/sessiondatapb/local_only_session_data.proto
+++ b/pkg/sql/sessiondatapb/local_only_session_data.proto
@@ -536,6 +536,12 @@ message LocalOnlySessionData {
   // plans in which every expression has a bounded cardinality over plans with
   // one or more expressions with unbounded cardinality.
   bool optimizer_prefer_bounded_cardinality = 154;
+  // OptimizerMinRowCount set a lower bound on row count estimates for
+  // relational expressions during query planning. A value of zero indicates no
+  // lower bound. Note that if this is set to a value greater than zero, a row
+  // count of zero can still be estimated for expressions with a cardinality of
+  // zero, e.g., for a contradictory filter.
+  double optimizer_min_row_count = 155;
 
   ///////////////////////////////////////////////////////////////////////////
   // WARNING: consider whether a session parameter you're adding needs to  //
diff --git a/pkg/sql/vars.go b/pkg/sql/vars.go
index ab82913ece18..10b9b200e4ad 100644
--- a/pkg/sql/vars.go
+++ b/pkg/sql/vars.go
@@ -3491,6 +3491,30 @@ var varGen = map[string]sessionVar{
 		},
 		GlobalDefault: globalFalse,
 	},
+
+	`optimizer_min_row_count`: {
+		GetStringVal: makeFloatGetStringValFn(`optimizer_min_row_count`),
+		Get: func(evalCtx *extendedEvalContext, _ *kv.Txn) (string, error) {
+			return formatFloatAsPostgresSetting(evalCtx.SessionData().OptimizerMinRowCount), nil
+		},
+		GlobalDefault: func(sv *settings.Values) string {
+			return "0"
+		},
+		Set: func(_ context.Context, m sessionDataMutator, s string) error {
+			f, err := strconv.ParseFloat(s, 64)
+			if err != nil {
+				return err
+			}
+			// Note that we permit fractions above 1.0 to allow for giving
+			// head-of-the-line batch more memory that is available - this will
+			// put the budget in debt.
+			if f < 0 {
+				return pgerror.New(pgcode.InvalidParameterValue, "optimizer_min_row_count must be non-negative")
+			}
+			m.SetOptimizerMinRowCount(f)
+			return nil
+		},
+	},
 }
 
 func ReplicationModeFromString(s string) (sessiondatapb.ReplicationMode, error) {