risingwavelabs/risingwave · error

UDF returned columns, but expected 2

Error message

UDF returned {} columns, but expected 2

What it means

`check_output` in the user-defined table function validates every output chunk of a generate-series-style UDF. Such UDFs must return exactly two columns: an Int32 row index and a value column; any other column count fails with this error.

Solutions

  1. Fix the UDF to return exactly two columns: (Int32 row index, value column).
  2. Check the UDF client/SDK wrapper that converts the UDF's return value into an Arrow RecordBatch for accidental column drops/additions.
  3. Pin/align the UDF service version with the RisingWave UDF protocol you coded against.

Example fix

// before (Python UDF)
def gen(n):
    return [i for i in range(n)]  # 1 column
// after
def gen(n):
    return list(range(n)), [x * 2 for x in range(n)]  # (index, value) => 2 columns
Defensive patterns

Strategy: validation

Validate before calling

# caller-side pre-check on the UDF service output (pyarrow)
assert batch.num_columns == 2, f"UDF returned {batch.num_columns} columns"
assert batch.schema.field(0).type == pa.int32()

Try / catch

match table_function.eval_inner(input).await {
    Ok(chunk) => chunk,
    Err(e) if e.to_string().contains("columns, but expected 2") => {
        log::error!("UDF returned wrong column count; check UDF return shape");
        bail!(ExecError::UdfOutput(e));
    }
    Err(e) => return Err(e.into()),
}

Prevention

When it happens

Trigger: `eval_inner` invokes the external UDF, converts its Arrow RecordBatch to a `DataChunk`, and `check_output` finds `output.columns().len() != 2` — e.g. the UDF returns 1 or 3 columns.

Common situations: A Python UDF's return value is wrapped/unwrapped incorrectly (returning a single column instead of (index, value), or extra debug columns); a UDF library version change altered the returned batch shape.

Related errors


AI-assisted analysis of risingwavelabs/risingwave@6469eb736d (2026-09-11). Data as JSON: /api/errors/b3597bdcccb7d4e1. Report an issue: GitHub.

Appendix: source

Thrown at src/expr/core/src/table_function/user_defined.rs:93

                .column_at(0)
                .as_int32()
                .raw_iter()
                // we have checked all indices are non-negative
                .map(|idx| visible_rows[idx as usize] as i32)
                .collect::<I32Array>();

            let output = DataChunk::new(
                vec![origin_indices.into_ref(), output.column_at(1).clone()],
                output.visibility().clone(),
            );
            yield output;
        }
    }

    /// Check if the output chunk is valid.
    fn check_output(&self, output: &DataChunk) -> Result<()> {
        if output.columns().len() != 2 {
            bail!(
                "UDF returned {} columns, but expected 2",
                output.columns().len()
            );
        }
        if output.column_at(0).data_type() != DataType::Int32 {
            bail!(
                "UDF returned {:?} at column 0, but expected {:?}",
                output.column_at(0).data_type(),
                DataType::Int32,
            );
        }
        if output.column_at(0).as_int32().raw_iter().any(|i| i < 0) {
            bail!("UDF returned negative row index");
        }
        if !output
            .column_at(1)
            .data_type()
            .equals_datatype(&self.return_type)

View on GitHub (pinned to 6469eb736d)