{"record":{"id":"df1e810116e41c3c","repo":"risingwavelabs/risingwave","slug":"udf-returned-rows-but-expected","errorCode":null,"errorMessage":"UDF returned {} rows, but expected {}","messagePattern":"UDF returned (.+?) rows, but expected (.+?)","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"src/expr/core/src/expr/expr_udf.rs","lineNumber":124,"sourceCode":"            .instrument_await(self.span.clone())\n            .await;\n\n        timer.stop_and_record();\n        if arrow_output_result.is_ok() {\n            &self.metrics.success_count\n        } else {\n            &self.metrics.failure_count\n        }\n        .inc();\n        // update memory usage\n        self.metrics\n            .memory_usage_bytes\n            .set(self.runtime.memory_usage() as i64);\n\n        let arrow_output = arrow_output_result?;\n\n        if arrow_output.num_rows() != input.cardinality() {\n            bail!(\n                \"UDF returned {} rows, but expected {}\",\n                arrow_output.num_rows(),\n                input.cardinality(),\n            );\n        }\n\n        let output = self.arrow_convert.from_record_batch(&arrow_output)?;\n        let output = output.expand_vis(input.visibility().clone());\n\n        let Some(array) = output.columns().first() else {\n            bail!(\"UDF returned no columns\");\n        };\n        if !array.data_type().equals_datatype(&self.return_type) {\n            bail!(\n                \"UDF returned {:?}, but expected {:?}\",\n                array.data_type(),\n                self.return_type,\n            );","sourceCodeStart":106,"sourceCodeEnd":142,"githubUrl":"https://github.com/risingwavelabs/risingwave/blob/6469eb736d691e8e9b8a419a57edd6429ca77417/src/expr/core/src/expr/expr_udf.rs#L106-L142","documentation":"After a UDF's Arrow computation runs, the expression validates that the output batch has exactly as many rows as the input batch (one output row per input row, respecting visibility). A row-count mismatch means the UDF implementation violated the vectorized UDF contract, so evaluation bails.","triggerScenarios":"A UDF implementation (e.g. Python/JS/remote UDF) returns an Arrow RecordBatch whose num_rows differs from input cardinality — e.g. filtering rows, aggregation inside a scalar UDF, or wrong-length output arrays.","commonSituations":"Custom UDFs doing unintended filtering or group-bys; external UDF services returning malformed batches; bugs in UDF SDKs generating output of the wrong length.","solutions":["Fix the UDF to return exactly one output row per input row","Check whether the UDF accidentally filters or aggregates; move that logic to a separate query construct","If the UDF is remote, inspect its response batch size and SDK conversion code","Add unit tests for the UDF with edge-case inputs (empty, null-containing batches)"],"exampleFix":"// before (UDF filters rows internally)\nlet filtered = batch.filter(...)?;\n// after\nlet filtered = batch; // return one row per input row","handlingStrategy":"try-catch","validationCode":"// In UDF implementation: assert output rows match input rows\nassert_eq!(output.num_rows(), input.num_rows(), \"UDF must be row-preserving\");","typeGuard":null,"tryCatchPattern":"match res { Err(e) if e.to_string().contains(\"UDF returned\") => { log_udf_contract_violation(&e); fallback_to_error_row() }, Ok(v) => v, Err(e) => return Err(e) }","preventionTips":["Write UDF unit tests asserting 1:1 input/output row counts","Avoid filtering/aggregating inside scalar UDFs","Test UDFs against empty and visibility-masked batches"],"tags":["rust","udf","arrow","contract-violation"],"backgroundTag":"unexpected-response-shape","analyzedSha":"6469eb736d691e8e9b8a419a57edd6429ca77417","analyzedAt":"2026-09-11T21:06:21.487Z","contentChangedAt":"2026-09-11T21:06:21.487Z","schemaVersion":2},"datasetVersion":"2026-09-14T16:17:12.679Z"}