Skip to content

Join behavior inconsistency with duckdb 2.0 #733

Open

Description

@j-carson

What happens?

When I join three tables with condition = 'column', I can select from all three joined tables
When I join with a condition table['column'] == table['column'], I can't select from all three tables using their table names

To Reproduce

#!/usr/bin/env python
# coding: utf-8

# In[1]:


import duckdb 
import pandas as pd
from duckdb import col

duckdb.__version__

#  '2.0.0'

# In[2]:


# Table 1's id1  is a foreign key referenced by both table 2 and table 3
table_1_df = pd.DataFrame({
    "id1": [1,2,3,4,5],
    "data": [1,2,3,4,5],
})

# Table 2 and table 3 share a data column name, so table references and aliases are needed
table_2_df = pd.DataFrame({
    "id2": list("abcde"),
    "id1": [1,2,3,4,5],
    "data": [1,2,3,4,5],
})

table_3_df = pd.DataFrame({
    "id3": list("fghij"),
    "id1": [1,2,3,4,5],
    "data": [1,2,3,4,5],
})

con = duckdb.connect()

con.register("table_one", table_1_df)
table_1 = con.table("table_one")

con.register("table_two", table_2_df)
table_2 = con.table("table_two")

con.register("table_three", table_3_df)
table_3 = con.table("table_three")


# In[3]:


# When a join USING(id1) query is created, all is well
using_query = (table_1
    .join(table_2,  condition= 'id1')
    .join(table_3, how="left", condition='id1')
)
using_query.select(
    col("table_one", "id1"),
    col("table_two", "data").alias("data2"),
    col("table_three", "data").alias("data3"),
)

# id1
# BIGINT	data2
# BIGINT	data3
# BIGINT
# 1	1	1
# 2	2	2
# 3	3	3
# 4	4	4
# 5	5	5


# In[4]:


# When a join with an equality expression is used, it does not work
condition_query = (table_1
    .join(table_2,  condition= lambda left,right: left['id1'] == right['id1'])
    .join(table_3, how="left", condition=lambda left,right: left['id1'] == right['id1'])
)

condition_query.select(
    col("table_one", "id1"),
    col("table_two", "data").alias("data2"),
    col("table_three", "data").alias("data3"),
)


# ---------------------------------------------------------------------------
# BinderException                           Traceback (most recent call last)
# Cell In[4], line 7
#       3     .join(table_2,  condition= lambda left,right: left['id1'] == right['id1'])
#       4     .join(table_3, how="left", condition=lambda left,right: left['id1'] == right['id1'])
#       5 )
#       6 
# ----> 7 condition_query.select(
#       8     col("table_one", "id1"),
#       9     col("table_two", "data").alias("data2"),
#      10     col("table_three", "data").alias("data3"),

# File ~/develop/jds-sql/.venv/lib/python3.12/site-packages/duckdb/_relation.py:3017, in _closed_as_relation.<locals>.guarded(*args, **kwargs)
#    3015     raise _duckdb.ConnectionException(message) from None
#    3016 except _duckdb.Error as error:
# -> 3017     raise _trimmed(error) from error.__cause__

# BinderException: Binder Error: Referenced table "table_one" not found!
# Candidate tables: "table_three"





OS:

Ubuntu

DuckDB Package Version:

2.0.0

Python Version:

3.12.3

Full Name:

Janet Carson

Affiliation:

Echodyne

What is the latest build you tested with? If possible, we recommend testing with the latest nightly build.

I have tested with a stable release

Did you include all relevant data sets for reproducing the issue?

Yes

Did you include all code required to reproduce the issue?

  • Yes, I have

Did you include all relevant configuration to reproduce the issue?

  • Yes, I have

Activity

  1. mohamd764 commented on Nov 1, 2026

    @mohamd764

    I could reproduce this on 2.0.0. From what I can tell it comes from the lambda condition, not from the number of tables. When join() gets a function as the condition it "closes" both sides (there's a comment about it around sides_closed in duckdb/_relation.py), so the first join result goes into the second join as a single item under its own generated alias. You can see it in the SQL:

    condition_query.sql_query()
    # WITH "__duckdb_s0" AS NOT MATERIALIZED (
    #   SELECT * FROM "table_one" AS "table_one" INNER JOIN "table_two" AS "table_two" ON (...)
    # )
    # SELECT * FROM "__duckdb_s0" AS "JOIN_RELATION_..." LEFT JOIN "table_three" AS "table_three" ON (...)

    So table_one and table_two aren't visible anymore, and the duplicate names get renamed (condition_query.columns gives ['id1', 'data', 'id2', 'id1_1', 'data_1', 'id3', 'id1', 'data']). The two-table lambda join works because nothing is wrapped yet.

    Workarounds that worked for me on 2.0.0 with your data:

    # 1) pass an expression instead of a lambda, the FROM stays flat
    q = (table_1
        .join(table_2, condition=col("table_one", "id1") == col("table_two", "id1"))
        .join(table_3, how="left", condition=col("table_one", "id1") == col("table_three", "id1")))
    q.select(col("table_one", "id1"), col("table_two", "data").alias("data2"), col("table_three", "data").alias("data3"))
    
    # 2) a SQL string condition also keeps the table names
    #    condition="table_one.id1 = table_three.id1"
    
    # 3) keep the lambdas but give the inner join an alias you can reference
    j = table_1.join(table_2, condition=lambda l, r: l["id1"] == r["id1"]).set_alias("j")
    q = j.join(table_3, how="left", condition=lambda l, r: l["id1"] == r["id1"])
    q.select(col("j", "id1"), col("j", "data_1").alias("data2"), col("table_three", "data").alias("data3"))

    It looks intentional from the code, but it's surprising that the USING form and the lambda form scope names differently, so at least a note in the docs for the function condition would help.

  2. j-carson commented on Nov 1, 2026

    @j-carson
    Author

    @mohamd764 Thank you so much for your reply! It was absolutely vexing me why some of my complex joins that worked just fine in duckdb 1.5 were not porting to duckdb 2.0. I finally found at least one simple test case and logged it here...

    I was just following the examples on the relational API docs which suggest the lambda is the shiny new way to do things
    https://duckdb.org/docs/2.0/clients/python/relational_api#join and does not mention any downsides nor the other two methods shown above.

    I was basically doing option 2 in the duckdb 1.5 code that I was trying to port over -- so that change would be easy. Option 1 is kind of what I've been aiming for -- trying to reduce the hardcoded sql-strings and using more API's to manage the SQL for me, so I like that one better and will try that. For option 3, it seems to require knowing how duckdb will rename my columns under the covers, which might have a risk of breaking later, so not sure about it right now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions