{"record":{"id":"31a56bbc1a0282ef","repo":"windmill-labs/windmill","slug":"cannot-parse-s-as-numeric-e","errorCode":null,"errorMessage":"Cannot parse '{s}' as numeric: {e}","messagePattern":"Cannot parse '(.+?)' as numeric: (.+?)","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"backend/windmill-worker/src/pg_executor.rs","lineNumber":1644,"sourceCode":"                Error::ExecutionErr(format!(\"Cannot parse '{s}' as timestamptz: {e}\"))\n            })?;\n            Ok((Box::new(datetime), Type::TIMESTAMPTZ))\n        }\n        Value::String(s) if arg_t == \"bytea\" => {\n            let bytes = engine::general_purpose::STANDARD\n                .decode(s)\n                .unwrap_or(vec![]);\n            Ok((Box::new(bytes), Type::BYTEA))\n        }\n        // Parse Strings into the matching native Rust type for the remaining\n        // recognised arg_ts that didn't have a dedicated arm. Without these,\n        // a string value lands in the generic Value::String fallback below\n        // (Box<String> + TEXT) and the server-side comparison\n        // `<numeric|real|...> = text` fails since PG has no implicit cast.\n        Value::String(s) if arg_t == \"numeric\" || arg_t == \"decimal\" => s\n            .parse::<Decimal>()\n            .map(|d| (Box::new(d) as Box<dyn ToSql + Sync + Send>, Type::NUMERIC))\n            .map_err(|e| anyhow::anyhow!(\"Cannot parse '{s}' as numeric: {e}\").into()),\n        Value::String(s) if arg_t == \"real\" || arg_t == \"float4\" => s\n            .parse::<f32>()\n            .map(|n| (Box::new(n) as Box<dyn ToSql + Sync + Send>, Type::FLOAT4))\n            .map_err(|e| anyhow::anyhow!(\"Cannot parse '{s}' as real: {e}\").into()),\n        Value::String(s)\n            if arg_t == \"double\" || arg_t == \"double precision\" || arg_t == \"float8\" =>\n        {\n            s.parse::<f64>()\n                .map(|n| (Box::new(n) as Box<dyn ToSql + Sync + Send>, Type::FLOAT8))\n                .map_err(|e| anyhow::anyhow!(\"Cannot parse '{s}' as double: {e}\").into())\n        }\n        Value::String(s) if arg_t == \"oid\" => s\n            .parse::<u32>()\n            .map(|n| (Box::new(n) as Box<dyn ToSql + Sync + Send>, Type::OID))\n            .map_err(|e| anyhow::anyhow!(\"Cannot parse '{s}' as oid: {e}\").into()),\n        Value::String(s) if arg_t == \"bool\" || arg_t == \"boolean\" => {\n            // Accept the same literals Postgres' boolin() does.\n            let b = match s.to_ascii_lowercase().as_str() {","sourceCodeStart":1626,"sourceCodeEnd":1662,"githubUrl":"https://github.com/windmill-labs/windmill/blob/e474e8803ce2ff5c2df09a58dab51d45f5c922ca/backend/windmill-worker/src/pg_executor.rs#L1626-L1662","documentation":"convert_val converts a JSON string argument into a tokio-postgres ToSql parameter. When the declared arg type is numeric/decimal, it parses the string into a rust_decimal Decimal and wraps any parse failure as 'Cannot parse ... as numeric'. The comment in the source notes the value deliberately goes through client-side parsing so Postgres doesn't fail on a `<numeric> = text` comparison.","triggerScenarios":"A step argument typed numeric/decimal receives a Value::String that rust_decimal cannot parse: 'NaN', 'Infinity', '1,234.56' with thousand separator, an exponent form Decimal rejects, or non-numeric text.","commonSituations":"European decimal comma format ('12,50'); currency strings ('$19.99'); NaN/Infinity produced by JS math then stringified; numbers pasted from spreadsheets with grouping separators or currency symbols.","solutions":["Normalize the string to plain decimal notation (dot separator, no symbols or grouping) before invoking the step","Strip currency symbols and thousand separators in the step script before passing the value","Guard against NaN/Infinity upstream (they are not valid decimals) and substitute null or a sentinel","Pass a JSON number when precision allows; reserve string only for high-precision values"],"exampleFix":"// before\nargs: { price: \"€1.234,56\" }\n// after\nargs: { price: \"1234.56\" }  // normalized decimal string","handlingStrategy":"validation","validationCode":"function assertDecimal(s) {\n  if (typeof s !== \"string\") return;\n  const normalized = s.trim().replace(/^[^\\d-+]+/, \"\").replace(/,/g, m => m === \",\" ? \".\" : m);\n  if (!/^[+-]?\\d+(\\.\\d+)?$/.test(normalized) || /nan|infinity/i.test(s)) {\n    throw new Error(`value ${JSON.stringify(s)} is not a plain decimal`);\n  }\n}","typeGuard":"const isPlainDecimal = (v) => typeof v === \"string\" && /^[+-]?\\d+(\\.\\d+)?$/.test(v.trim());","tryCatchPattern":"try {\n  await runPgStep(args);\n} catch (e) {\n  if (String(e.message).includes(\"as numeric\")) {\n    throw new Error(`Argument must be a plain decimal string, got: ${e.message}`);\n  }\n  throw e;\n}","preventionTips":["Normalize locale formats (decimal comma, currency symbols) before passing","Never stringify NaN/Infinity into a numeric arg","Keep high-precision money values as strings but strip formatting first"],"tags":["postgresql","parse-error","decimal","type-conversion"],"backgroundTag":"invalid-number-format","analyzedSha":"e474e8803ce2ff5c2df09a58dab51d45f5c922ca","analyzedAt":"2026-09-03T12:38:19.024Z","contentChangedAt":"2026-09-03T12:38:19.024Z","schemaVersion":2},"datasetVersion":"2026-09-08T10:18:20.063Z"}