You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Is your feature request related to a problem or challenge?
When a SQL SELECT item has no alias, DataFusion names the output column with the planner's internal lookup key. The headers are hard to read and often very wide:
Query item
Column name today
1 + 2
Int64(1) + Int64(2)
row_number() OVER (ORDER BY a)
row_number() ORDER BY [t.a ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
Why the names look like this
Expr::schema_name() has two jobs:
Display label. It becomes the column header that you see in query results.
Lookup key. The planner uses it to find columns. For example, a parent plan finds an aggregate's output with Column::from_name(expr.schema_name()) in datafusion/expr/src/utils.rs.
The key needs to be unique and unambiguous, so it includes type wrappers (Int64(1) versus Utf8("1")) and table qualifiers (t.a). Those same details make headers hard to read. Shortening the key risks giving two different expressions the same key. For example, CAST is left out of names (#3222), so SELECT CAST(a AS DOUBLE), a::VARCHAR fails with a unique-name error.
Describe the solution you'd like
Add an opt-in setting that gives each unaliased output column of a SQL query a readable label. The label is computed once, after the query is planned, and applied as an alias on the query result. The internal key doesn't change, so name resolution, views, and the DataFrame API keep working, and query execution speed doesn't change.
Proposed behavior
The current names in these tables come from running the queries on the main branch.
Expressions
Query item
Current name
Proposed name
1, 'foo'
Int64(1), Utf8("foo")
1, foo
1 + 2
Int64(1) + Int64(2)
1 + 2
(a + b) * c
t.a + t.b * t.c
(a + b) * c
t.a + 1
t.a + Int64(1)
a + 1
coalesce(NULL, 1)
coalesce(NULL,Int64(1))
coalesce(NULL, 1)
CASE WHEN a > 0 THEN 'pos' ELSE 'neg' END
CASE WHEN t.a > Int64(0) THEN Utf8("pos") ELSE Utf8("neg") END
CASE WHEN a > 0 THEN pos ELSE neg END
Aggregate functions
Query item
Current name
Proposed name
sum(a), count(DISTINCT b)
sum(t.a), count(DISTINCT t.b)
sum(a), count(DISTINCT b)
avg(c) FILTER (WHERE c > 5)
avg(t.c) FILTER (WHERE t.c > Int64(5))
avg(c) FILTER (WHERE c > 5)
count(*)
count(*)
count(*) (planner alias)
count(1)
count(Int64(1))
count(1)
Window functions
Query item
Current name
Proposed name
row_number() OVER (ORDER BY a)
row_number() ORDER BY [t.a ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
row_number() OVER (ORDER BY a)
sum(a) OVER (PARTITION BY b)
sum(t.a) PARTITION BY [t.b] ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
sum(a) OVER (PARTITION BY b)
sum(a) OVER (ORDER BY a DESC NULLS FIRST)
sum(t.a) ORDER BY [t.a DESC NULLS FIRST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
sum(a) OVER (ORDER BY a DESC)
sum(a) OVER (ORDER BY a ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
sum(t.a) ORDER BY [t.a ASC NULLS LAST] ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
sum(a) OVER (ORDER BY a ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
sum(b) OVER (RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
sum(t.b) ORDER BY [UInt64(1) ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
sum(b) OVER ()
Columns from CTEs, derived tables, and views
Query
Current name
Proposed name
WITH x AS (SELECT sum(a) FROM t GROUP BY b) SELECT * FROM x
sum(t.a)
sum(a)
SELECT * FROM (SELECT a + 1 FROM t)
t.a + Int64(1)
a + 1
SELECT * FROM v, where v is a view over SELECT a + 1 ...
t.a + Int64(1)
a + 1
SELECT x FROM (SELECT a + 1 AS x FROM t)
x
x (explicit alias)
SELECT "t.a + Int64(1)" FROM (SELECT a + 1 FROM t)
t.a + Int64(1)
t.a + Int64(1) (as typed)
Naming rules
Literals are plain values, and column references drop their qualifier. Operators and function arguments use SQL spacing, with parentheses where operator precedence needs them.
Aggregate FILTER and ORDER BY clauses follow the same rules.
Explicit aliases, at any level, and columns that you name in the outermost SELECT keep their names. Columns from * and computed expressions get labels.
Scope
Readable labels apply only to the result of a top-level SQL query statement. Labels are added after the whole query plan is built, so every column inside the plan keeps its current name, and these references keep working:
Outer queries that reference an inner column by its current name, such as "t.a + Int64(1)".
ORDER BY, HAVING, and GROUP BY references, including ORDER BY by the current name, such as ORDER BY "t.a + Int64(1)".
Labels are a final step in the Statement::Query branch of sql_statement_to_plan in datafusion/sql/src/statement.rs:
The SQL planner builds the complete query plan as it does today, including ORDER BY, HAVING, QUALIFY, DISTINCT, and UNNEST.
For each output column of the plan, DataFusion computes a label with label_for(plan, column).
If at least one label differs from the current name, DataFusion adds a projection on top of the plan that aliases each column to its label.
To keep the name that you typed for an explicit column reference, DataFusion collects the identifiers that appear as plain column items in the outermost SELECT list. An output column that is a plain column reference with one of those names keeps its name.
Tracing each column to its origin
A label comes from the expression that produced a column, which is often lower in the plan than the outermost projection. The SQL planner replaces aggregate and window calls with key-named column references (aggregate() and rebase_expr in datafusion/sql/src/select.rs), so the outermost projection for SELECT sum(a) + 1 FROM t refers to a column named sum(t.a). Columns from SELECT * are produced inside the CTE, derived table, or view. Views need no special case, because LogicalPlanBuilder::scan_with_filters expands a view into its own plan.
label_for(plan, column) follows the column down through the plan and returns a label or None:
Plan node
Trace step
Projection
Find the expression at the column's index. An explicit alias stops the trace and keeps its name. A column reference continues into the input. Other expressions are rendered, and each column inside them is traced recursively.
Aggregate
Render the grouping or aggregate expression at the column's index.
Window
Render the window expression at the column's index.
SubqueryAlias, Filter, Sort, Limit, Distinct::All
Continue into the input at the same field index.
Join with qualified columns
Continue into the side that owns the field index.
Union
Continue into the first branch. SQL takes output names from the first branch.
TableScan
Return the plain column name.
Join with USING or NATURAL, Distinct::On, Unnest, RecursiveQuery, Values, EmptyRelation, Extension
Return None.
The trace follows field indexes instead of names, so it doesn't depend on how each node qualifies its fields. If the trace returns None for any column inside an expression, the whole label is None. A column with a None label keeps its current name.
Why labels are computed before optimization
The optimizer folds 1 + 2 into the constant 3 but keeps the original name. Tracing the plan before optimization keeps the label 1 + 2.
Why collisions fall back to current names
Two different expressions can have the same readable label. For example, SELECT t1.a + 1, t2.a + 1 produces a + 1 twice, and SELECT 1, '1' produces 1 twice. The planner requires unique names in a projection, so aliasing both columns to the same label fails. Keeping the current name for colliding columns means the setting never makes a working query fail.
Rendering window functions
The window label is name(args) OVER (PARTITION BY ... ORDER BY ... frame), with each part left out when it equals its default:
Part
Left out when
Window frame
It equals the planner's default for this ORDER BY: WindowFrame::new, with the same tie check that datafusion/sql/src/expr/function.rs uses.
Constant ORDER BY keys
Always. The planner adds ORDER BY 1 when a RANGE frame has no ORDER BY (WindowFrame::regularize_order_bys), and a constant key doesn't change the result.
ASC
Always, because it's the default sort direction.
NULLS LAST and NULLS FIRST
The combination is ASC NULLS LAST or DESC NULLS FIRST.
PARTITION BY and ORDER BY
The list is empty.
An explicit ROWS frame on an ORDER BY that can have ties stays in the label, because tied rows give different results than the default RANGE frame.
A named window, such as OVER w, is expanded before planning, so its label shows the full OVER (...) definition.
Performance
Query execution
Execution speed doesn't change:
The label is an alias. SELECT a + 1 AS "a + 1" runs exactly like SELECT a + 1 AS x, which you can write today.
The labeling projection usually merges into the query's own projection (merge_consecutive_projections). Otherwise it's a rename-only ProjectionExec that reuses the input arrays without copying them.
A column whose label equals its current name gets no alias. For example, SELECT * FROM big_table has the same plan with the setting on or off.
Query planning
The labeling step runs once per query. Its cost is one trace per output column, as deep as the plan, with no lookups by column name.
Benchmarks
Compare the setting on and off with logical_select_all_from_1000, physical_select_all_from_1000, physical_select_aggregates_from_200, logical_wide_aggregate_100_exprs, physical_plan_tpch_all, and physical_plan_tpcds_all in datafusion/core/benches/sql_planner.rs. Also add a query benchmark with an aggregate, ORDER BY, and LIMIT, to confirm that TopK and limit pushdown still apply.
Compatibility
Configuration
Add a boolean option in the datafusion.sql_parser namespace, next to enable_ident_normalization and default_null_ordering in datafusion/common/src/config.rs. The default is false. While the option is false, DataFusion behaves exactly as it does today, and the only planning cost is reading one configuration value.
Decide separately whether to change the default. Changing it is a breaking change for client code that reads columns by name.
What changes when you turn on the option
Affected area
Effect
Impact
Client code that reads result columns by name, such as df["sum(t.a)"] in Python, JDBC, ADBC, Flight SQL, or Ballista
Lookups by the old name fail
High
Code that calls ctx.sql() and then uses DataFrame methods with generated names, such as .select(col("sum(t.a)"))
Lookups by the old name fail
High
Tests that print table headers in .rs and .snap files (about 172 header lines contain type wrappers)
Snapshots change
Medium
EXPLAIN output in .slt files
The top projection shows the new aliases
Medium
Substrait and protobuf round-trip tests
Top-level output names change
Low
Unparser (plan to SQL)
Output includes AS "<label>", which is valid SQL
Low
Some required code changes also affect output when the option is off. The SqlDisplay and UdafHumanDisplayBuilder changes below change the text of EXPLAIN FORMAT TREE, which uses Expr::human_display().
Required code changes
In SqlDisplay (datafusion/expr/src/expr.rs):
Add a ScalarFunction case. Today this case falls back to the generic Display form, so coalesce(NULL, 1) renders as coalesce(NULL, Int64(1)).
Add parentheses by operator precedence in the BinaryExpr case. Today (a + b) * c renders as a + b * c.
Fix GroupingSet::Cube, which prints ROLLUP ( instead of CUBE (.
In UdafHumanDisplayBuilder (datafusion/expr/src/udaf.rs), render the FILTER clause with SqlDisplay instead of Display, and render the ORDER BY clause in SQL syntax instead of with schema_name_from_sorts. Aggregate functions that override AggregateUDFImpl::human_display keep their own format.
Add window rendering to the labeling code. It needs the input schema for the tie check.
Add a helper that removes qualifiers from an expression by using transform before rendering.
Add label_for and the labeling step in the Statement::Query branch of sql_statement_to_plan.
Add tests for each row in Proposed behavior, and for ORDER BY by the current name, ORDER BY by position, HAVING, DISTINCT ON, UNNEST, joins with USING, set operations, recursive CTEs, and window functions ordered by a unique key.
Update docs/source/contributor-guide/specification/output-field-name-semantic.md. The proposal replaces three of its rules for SQL results when the option is on:
Compound column names drop their qualifier (a + b, not t.a + t.b).
Operators get parentheses only where precedence needs them (1 + 2, not (1 + 2)).
Negation renders as (- a), the current SqlDisplay form.
DataFrame output keeps the rules in the specification.
Use a fixed name, such as Postgres ?column? or Hive _c0. These names keep headers short, but they drop what each column contains. ?column? also repeats, so it needs the same collision handling.
Attach the label as field metadata instead of renaming. The labeling step would add a metadata key, such as datafusion.label, and keep every column name unchanged. datafusion-cli and DataFrame::show() would display the label. This alternative changes no names, so it has no compatibility impact and no collisions. The trade-off: clients that don't read the metadata, such as Python, JDBC, ADBC, and Flight SQL, still see today's names.
Label each SELECT block during planning. A flag in PlannerContext would mark the outermost block. query_to_plan clones the context for every nested query, so each nested call site would have to clear the flag, and labels could reach ORDER BY and HAVING resolution in the same block.
Separate identity from names completely. Every expression would get an opaque identifier, as Spark does with expression IDs, which is a large change to Column and DFSchema. This proposal is a step in that direction, and name-based lookups can move to comparing expressions one at a time, as ORDER BY already does.
Additional context
Complaints not addressed
Width. A label is as long as the expression that it describes. A long expression, or a window function with an explicit frame, still produces a wide header.
The example in beautify default column names #2027.coalesce(NULL, NULL, NULL, NULL) already has no type wrappers today. The label adds a space after each comma, so it's 3 characters wider.
Out of scope
These queries fail today because two expressions share a lookup key. Labels don't fix them:
Option name and namespace. Is datafusion.sql_parser.pretty_column_names a good name, or does the option belong in another namespace, such as datafusion.format?
SQL followed by DataFrame calls. With the option on, a DataFrame returned by ctx.sql() uses the labels as column names. Is documenting this enough?
Collision handling. Should colliding columns keep their current names one at a time, as proposed, or should the whole query keep current names when any label collides? Numbered suffixes, such as a + 1 and a + 1_1, are a third option.
Session-dependent labels. A window label shows its NULLS clause when default_null_ordering isn't the default, so the same query can get different headers in different sessions. Is that acceptable?
Custom window display. Aggregate functions that override window_function_display_name lose that format in window labels. Should window functions get a human_display method, which changes the public API?
Related: #2027, #5174, #10274, #3222
Is your feature request related to a problem or challenge?
When a SQL
SELECTitem has no alias, DataFusion names the output column with the planner's internal lookup key. The headers are hard to read and often very wide:1 + 2Int64(1) + Int64(2)row_number() OVER (ORDER BY a)row_number() ORDER BY [t.a ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWWhy the names look like this
Expr::schema_name()has two jobs:Column::from_name(expr.schema_name())indatafusion/expr/src/utils.rs.The key needs to be unique and unambiguous, so it includes type wrappers (
Int64(1)versusUtf8("1")) and table qualifiers (t.a). Those same details make headers hard to read. Shortening the key risks giving two different expressions the same key. For example, CAST is left out of names (#3222), soSELECT CAST(a AS DOUBLE), a::VARCHARfails with a unique-name error.Describe the solution you'd like
Add an opt-in setting that gives each unaliased output column of a SQL query a readable label. The label is computed once, after the query is planned, and applied as an alias on the query result. The internal key doesn't change, so name resolution, views, and the DataFrame API keep working, and query execution speed doesn't change.
Proposed behavior
The current names in these tables come from running the queries on the
mainbranch.Expressions
1,'foo'Int64(1),Utf8("foo")1,foo1 + 2Int64(1) + Int64(2)1 + 2(a + b) * ct.a + t.b * t.c(a + b) * ct.a + 1t.a + Int64(1)a + 1coalesce(NULL, 1)coalesce(NULL,Int64(1))coalesce(NULL, 1)CASE WHEN a > 0 THEN 'pos' ELSE 'neg' ENDCASE WHEN t.a > Int64(0) THEN Utf8("pos") ELSE Utf8("neg") ENDCASE WHEN a > 0 THEN pos ELSE neg ENDAggregate functions
sum(a),count(DISTINCT b)sum(t.a),count(DISTINCT t.b)sum(a),count(DISTINCT b)avg(c) FILTER (WHERE c > 5)avg(t.c) FILTER (WHERE t.c > Int64(5))avg(c) FILTER (WHERE c > 5)count(*)count(*)count(*)(planner alias)count(1)count(Int64(1))count(1)Window functions
row_number() OVER (ORDER BY a)row_number() ORDER BY [t.a ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWrow_number() OVER (ORDER BY a)sum(a) OVER (PARTITION BY b)sum(t.a) PARTITION BY [t.b] ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWINGsum(a) OVER (PARTITION BY b)sum(a) OVER (ORDER BY a DESC NULLS FIRST)sum(t.a) ORDER BY [t.a DESC NULLS FIRST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWsum(a) OVER (ORDER BY a DESC)sum(a) OVER (ORDER BY a ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)sum(t.a) ORDER BY [t.a ASC NULLS LAST] ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWsum(a) OVER (ORDER BY a ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)sum(b) OVER (RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)sum(t.b) ORDER BY [UInt64(1) ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWsum(b) OVER ()Columns from CTEs, derived tables, and views
WITH x AS (SELECT sum(a) FROM t GROUP BY b) SELECT * FROM xsum(t.a)sum(a)SELECT * FROM (SELECT a + 1 FROM t)t.a + Int64(1)a + 1SELECT * FROM v, wherevis a view overSELECT a + 1 ...t.a + Int64(1)a + 1SELECT x FROM (SELECT a + 1 AS x FROM t)xx(explicit alias)SELECT "t.a + Int64(1)" FROM (SELECT a + 1 FROM t)t.a + Int64(1)t.a + Int64(1)(as typed)Naming rules
FILTERandORDER BYclauses follow the same rules.OVER (...)syntax without default parts. See Rendering window functions.SELECTkeep their names. Columns from*and computed expressions get labels.Scope
Readable labels apply only to the result of a top-level SQL query statement. Labels are added after the whole query plan is built, so every column inside the plan keeps its current name, and these references keep working:
"t.a + Int64(1)".ORDER BY,HAVING, andGROUP BYreferences, includingORDER BYby the current name, such asORDER BY "t.a + Int64(1)".These parts of DataFusion keep today's names:
CREATE VIEW,CREATE TABLE AS,INSERT ... SELECT,COPY, andDESCRIBE. Stored column names don't change.Design
Where labels are applied
Labels are a final step in the
Statement::Querybranch ofsql_statement_to_planindatafusion/sql/src/statement.rs:ORDER BY,HAVING,QUALIFY,DISTINCT, andUNNEST.label_for(plan, column).To keep the name that you typed for an explicit column reference, DataFusion collects the identifiers that appear as plain column items in the outermost
SELECTlist. An output column that is a plain column reference with one of those names keeps its name.Tracing each column to its origin
A label comes from the expression that produced a column, which is often lower in the plan than the outermost projection. The SQL planner replaces aggregate and window calls with key-named column references (
aggregate()andrebase_exprindatafusion/sql/src/select.rs), so the outermost projection forSELECT sum(a) + 1 FROM trefers to a column namedsum(t.a). Columns fromSELECT *are produced inside the CTE, derived table, or view. Views need no special case, becauseLogicalPlanBuilder::scan_with_filtersexpands a view into its own plan.label_for(plan, column)follows the column down through the plan and returns a label orNone:ProjectionAggregateWindowSubqueryAlias,Filter,Sort,Limit,Distinct::AllJoinwith qualified columnsUnionTableScanJoinwithUSINGorNATURAL,Distinct::On,Unnest,RecursiveQuery,Values,EmptyRelation,ExtensionNone.The trace follows field indexes instead of names, so it doesn't depend on how each node qualifies its fields. If the trace returns
Nonefor any column inside an expression, the whole label isNone. A column with aNonelabel keeps its current name.Why labels are computed before optimization
The optimizer folds
1 + 2into the constant3but keeps the original name. Tracing the plan before optimization keeps the label1 + 2.Why collisions fall back to current names
Two different expressions can have the same readable label. For example,
SELECT t1.a + 1, t2.a + 1producesa + 1twice, andSELECT 1, '1'produces1twice. The planner requires unique names in a projection, so aliasing both columns to the same label fails. Keeping the current name for colliding columns means the setting never makes a working query fail.Rendering window functions
The window label is
name(args) OVER (PARTITION BY ... ORDER BY ... frame), with each part left out when it equals its default:ORDER BY:WindowFrame::new, with the same tie check thatdatafusion/sql/src/expr/function.rsuses.ORDER BYkeysORDER BY 1when aRANGEframe has noORDER BY(WindowFrame::regularize_order_bys), and a constant key doesn't change the result.ASCNULLS LASTandNULLS FIRSTASC NULLS LASTorDESC NULLS FIRST.PARTITION BYandORDER BYAn explicit
ROWSframe on anORDER BYthat can have ties stays in the label, because tied rows give different results than the defaultRANGEframe.A named window, such as
OVER w, is expanded before planning, so its label shows the fullOVER (...)definition.Performance
Query execution
Execution speed doesn't change:
SELECT a + 1 AS "a + 1"runs exactly likeSELECT a + 1 AS x, which you can write today.merge_consecutive_projections). Otherwise it's a rename-onlyProjectionExecthat reuses the input arrays without copying them.SELECT * FROM big_tablehas the same plan with the setting on or off.Query planning
The labeling step runs once per query. Its cost is one trace per output column, as deep as the plan, with no lookups by column name.
Benchmarks
Compare the setting on and off with
logical_select_all_from_1000,physical_select_all_from_1000,physical_select_aggregates_from_200,logical_wide_aggregate_100_exprs,physical_plan_tpch_all, andphysical_plan_tpcds_allindatafusion/core/benches/sql_planner.rs. Also add a query benchmark with an aggregate,ORDER BY, andLIMIT, to confirm that TopK and limit pushdown still apply.Compatibility
Configuration
Add a boolean option in the
datafusion.sql_parsernamespace, next toenable_ident_normalizationanddefault_null_orderingindatafusion/common/src/config.rs. The default isfalse. While the option isfalse, DataFusion behaves exactly as it does today, and the only planning cost is reading one configuration value.Rollout
datafusion-cli, where beautify default column names #2027 first reported unreadable headers.What changes when you turn on the option
df["sum(t.a)"]in Python, JDBC, ADBC, Flight SQL, or Ballistactx.sql()and then uses DataFrame methods with generated names, such as.select(col("sum(t.a)")).rsand.snapfiles (about 172 header lines contain type wrappers)EXPLAINoutput in.sltfilesAS "<label>", which is valid SQLSome required code changes also affect output when the option is off. The
SqlDisplayandUdafHumanDisplayBuilderchanges below change the text ofEXPLAIN FORMAT TREE, which usesExpr::human_display().Required code changes
In
SqlDisplay(datafusion/expr/src/expr.rs):ScalarFunctioncase. Today this case falls back to the genericDisplayform, socoalesce(NULL, 1)renders ascoalesce(NULL, Int64(1)).BinaryExprcase. Today(a + b) * crenders asa + b * c.GroupingSet::Cube, which printsROLLUP (instead ofCUBE (.In
UdafHumanDisplayBuilder(datafusion/expr/src/udaf.rs), render theFILTERclause withSqlDisplayinstead ofDisplay, and render theORDER BYclause in SQL syntax instead of withschema_name_from_sorts. Aggregate functions that overrideAggregateUDFImpl::human_displaykeep their own format.Add window rendering to the labeling code. It needs the input schema for the tie check.
Add a helper that removes qualifiers from an expression by using
transformbefore rendering.Add
label_forand the labeling step in theStatement::Querybranch ofsql_statement_to_plan.Add tests for each row in Proposed behavior, and for
ORDER BYby the current name,ORDER BYby position,HAVING,DISTINCT ON,UNNEST, joins withUSING, set operations, recursive CTEs, and window functions ordered by a unique key.Update
docs/source/contributor-guide/specification/output-field-name-semantic.md. The proposal replaces three of its rules for SQL results when the option is on:a + b, nott.a + t.b).1 + 2, not(1 + 2)).(- a), the currentSqlDisplayform.DataFrame output keeps the rules in the specification.
Describe alternatives you've considered
schema_name()itself. Every place that resolves columns by name would see the new names, and different expressions would collide more often. beautify default column names #2027, cast expression may cause duplicate column name error #5174, and Better name display for CAST #10274 stalled on this problem.?column?or Hive_c0. These names keep headers short, but they drop what each column contains.?column?also repeats, so it needs the same collision handling.datafusion.label, and keep every column name unchanged.datafusion-cliandDataFrame::show()would display the label. This alternative changes no names, so it has no compatibility impact and no collisions. The trade-off: clients that don't read the metadata, such as Python, JDBC, ADBC, and Flight SQL, still see today's names.SELECTblock during planning. A flag inPlannerContextwould mark the outermost block.query_to_planclones the context for every nested query, so each nested call site would have to clear the flag, and labels could reachORDER BYandHAVINGresolution in the same block.ColumnandDFSchema. This proposal is a step in that direction, and name-based lookups can move to comparing expressions one at a time, asORDER BYalready does.Additional context
Complaints not addressed
coalesce(NULL, NULL, NULL, NULL)already has no type wrappers today. The label adds a space after each comma, so it's 3 characters wider.Out of scope
These queries fail today because two expressions share a lookup key. Labels don't fix them:
SELECT 1, 1SELECT CAST(a AS DOUBLE), a::VARCHAROpen questions
datafusion.sql_parser.pretty_column_namesa good name, or does the option belong in another namespace, such asdatafusion.format?ctx.sql()uses the labels as column names. Is documenting this enough?a + 1anda + 1_1, are a third option.NULLSclause whendefault_null_orderingisn't the default, so the same query can get different headers in different sessions. Is that acceptable?window_function_display_namelose that format in window labels. Should window functions get ahuman_displaymethod, which changes the public API?