Add Compiler Diagnostic Fix
dotnet/roslynator
A skill your agent uses when adding a Roslynator code fix for C compiler error CS or RCF, editing Diagnostics.xml or CodeFixes.xml, or when compiler-diagnostic-fixes-testing.md shows…
Malloy modeling mistakes and compile-error fixes. An agent skill from malloydata/publisher.
$ npx skills add malloydata/publisher --skill malloy-gotchas-modeling -a claude-codeProject install by default; add -g for ~/.claude/skills/.
$ gh skill install malloydata/publisher malloy-gotchas-modeling --agent claude-codeProject scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).
$ git clone --depth 1 https://github.com/malloydata/publisher.git skills-src && mkdir -p .claude/skills && cp -r skills-src/skills/malloy-gotchas-modeling .claude/skills/malloy-gotchas-modeling && rm -rf skills-srcUse ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.
Claude Code skills documentation · loads skills from .claude/skills/
Install the "malloy-gotchas-modeling" agent skill from https://github.com/malloydata/publisher/tree/main/skills/malloy-gotchas-modeling into .claude/skills/malloy-gotchas-modeling/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "malloy-gotchas-modeling", then confirm the skill loads.Claude Code copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$skill-installer install https://github.com/malloydata/publisher/tree/main/skills/malloy-gotchas-modelingType this inside Codex. $skill-installer <name> installs a curated skill from openai/skills. The installer writes to $CODEX_HOME/skills (default ~/.codex/skills). Restart Codex if the skill does not show up.
$ npx skills add malloydata/publisher --skill malloy-gotchas-modeling -a codexProject install goes to .agents/skills/; add -g for ~/.codex/skills/.
$ gh skill install malloydata/publisher malloy-gotchas-modeling --agent codexProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/malloydata/publisher.git skills-src && mkdir -p .agents/skills && cp -r skills-src/skills/malloy-gotchas-modeling .agents/skills/malloy-gotchas-modeling && rm -rf skills-srcUse ~/.agents/skills/ instead of .agents/skills for a personal install.
Codex skills documentation · loads skills from .agents/skills/
Install the "malloy-gotchas-modeling" agent skill from https://github.com/malloydata/publisher/tree/main/skills/malloy-gotchas-modeling into .agents/skills/malloy-gotchas-modeling/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "malloy-gotchas-modeling", then confirm the skill loads.Codex copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add malloydata/publisher --skill malloy-gotchas-modeling -a cursorProject install goes to .agents/skills/; add -g for ~/.cursor/skills/.
$ gh skill install malloydata/publisher malloy-gotchas-modeling --agent cursorProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/malloydata/publisher.git skills-src && mkdir -p .cursor/skills && cp -r skills-src/skills/malloy-gotchas-modeling .cursor/skills/malloy-gotchas-modeling && rm -rf skills-srcUse ~/.cursor/skills/ instead of .cursor/skills for a personal install.
Cursor skills documentation · loads skills from .cursor/skills/, .agents/skills/, .claude/skills/, .codex/skills/
Install the "malloy-gotchas-modeling" agent skill from https://github.com/malloydata/publisher/tree/main/skills/malloy-gotchas-modeling into .cursor/skills/malloy-gotchas-modeling/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "malloy-gotchas-modeling", then confirm the skill loads.Cursor copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gemini skills install https://github.com/malloydata/publisher.git --path skills/malloy-gotchas-modeling--scope user (default) or --scope workspace; --path is the subfolder of the repo that holds the skill; --consent skips the security confirmation prompt.
$ npx skills add malloydata/publisher --skill malloy-gotchas-modeling -a gemini-cliProject install goes to .agents/skills/; add -g for ~/.gemini/skills/.
$ gh skill install malloydata/publisher malloy-gotchas-modeling --agent gemini-cliProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/malloydata/publisher.git skills-src && mkdir -p .gemini/skills && cp -r skills-src/skills/malloy-gotchas-modeling .gemini/skills/malloy-gotchas-modeling && rm -rf skills-srcUse ~/.gemini/skills/ instead of .gemini/skills for a personal install, then run /skills reload.
Gemini CLI skills documentation · loads skills from .gemini/skills/, .agents/skills/
Install the "malloy-gotchas-modeling" agent skill from https://github.com/malloydata/publisher/tree/main/skills/malloy-gotchas-modeling into .gemini/skills/malloy-gotchas-modeling/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "malloy-gotchas-modeling", then confirm the skill loads.Gemini CLI copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gh skill install malloydata/publisher malloy-gotchas-modelingInstalls for Copilot at project scope by default; add --scope user for a personal install. Preview a skill first with gh skill preview. Needs GitHub CLI 2.90.0 or later (public preview).
$ npx skills add malloydata/publisher --skill malloy-gotchas-modeling -a github-copilotProject install goes to .agents/skills/; add -g for ~/.copilot/skills/.
$ git clone --depth 1 https://github.com/malloydata/publisher.git skills-src && mkdir -p .github/skills && cp -r skills-src/skills/malloy-gotchas-modeling .github/skills/malloy-gotchas-modeling && rm -rf skills-srcUse ~/.copilot/skills/ instead of .github/skills for a personal install. Commit .github/skills so cloud agent and code review can use it.
GitHub Copilot skills documentation · loads skills from .github/skills/, .claude/skills/, .agents/skills/
Install the "malloy-gotchas-modeling" agent skill from https://github.com/malloydata/publisher/tree/main/skills/malloy-gotchas-modeling into .github/skills/malloy-gotchas-modeling/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "malloy-gotchas-modeling", then confirm the skill loads.GitHub Copilot copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add malloydata/publisher --skill malloy-gotchas-modeling -a opencodeOpenCode documents no install command of its own. Project install goes to .agents/skills/; add -g for ~/.config/opencode/skills/.
$ gh skill install malloydata/publisher malloy-gotchas-modeling --agent opencodeProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/malloydata/publisher.git skills-src && mkdir -p .opencode/skills && cp -r skills-src/skills/malloy-gotchas-modeling .opencode/skills/malloy-gotchas-modeling && rm -rf skills-srcUse ~/.config/opencode/skills/ instead of .opencode/skills for a personal install.
OpenCode skills documentation · loads skills from .opencode/skills/, .claude/skills/, .agents/skills/
Install the "malloy-gotchas-modeling" agent skill from https://github.com/malloydata/publisher/tree/main/skills/malloy-gotchas-modeling into .opencode/skills/malloy-gotchas-modeling/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "malloy-gotchas-modeling", then confirm the skill loads.OpenCode copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
malloy-gotchas-modelingMalloy modeling mistakes and compile-error fixes. An agent skill from malloydata/publisher.
Malloy Gotchas Modeling is an agent skill from malloydata/publisher. Malloy modeling mistakes and compile-error fixes. Read before writing sources, dimensions, measures or joins, and when a .malloy file will not compile. Reserved words, NULLs, dates, field access.
Its SKILL.md is about 8.7k tokens, which your agent loads only when the skill is triggered. It is a single SKILL.md file with no bundled scripts.
The repository describes itself as: Publisher is the open-source analytics engine for Malloy. It lets you define data models once — and use them everywhere. The licence is MIT.
6 steps, taken from the first numbered list in SKILL.md.
Read from SKILL.md and the folder at commit b9a1a19. It shows what the files ask for, not the result of running them.
Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.
From allowed-tools in the SKILL.md frontmatter.
No scripts in the folder and no shell commands in SKILL.md (its code samples are malloy and json).
From the folder's file list and the shell code blocks in SKILL.md.
Links to these hosts (documentation or services it may open):
docs.malloydata.devFrom URLs in SKILL.md, links to its own repository left out.
Names no API keys, tokens, secrets or passwords.
From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.
Malloy Gotchas Modeling loads about 8.7k tokens when it runs. Until then it costs about 55 tokens; SKILL.md has 4,052 words of instructions outside code blocks.
Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.
The automated check found no risky patterns in SKILL.md.
Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.
The full file from malloydata/publisher at commit b9a1a19, republished under its MIT licence (© malloydata). 4,052 words, ~8,700 tokens.
.claude/skills/malloy-gotchas-modeling/SKILL.md (or your agent's skills folder).<!--
Copyright (c) Credible Data Inc.
SPDX-License-Identifier: MIT
-->
Read this before writing Malloy code. These patterns cause most modeling errors.
Tool names are written bare here -
get_context,execute_query,search_malloy_docs. The exact prefixed name depends on the host surface; match each against the tools you actually have.
Get the error. Use an editor-diagnostics tool if your host has one. Otherwise run any query against the source with execute_query and read the error it returns; every host that can run Malloy can do this. Only ask the user to open the file in an editor when you know they have it open there.
Errors cascade. Fix the FIRST error only, recompile, repeat; later errors are often caused by it. If the message is unclear, call search_malloy_docs with the message text.
| Error | Fix |
|---|---|
| "Unknown field" | Check the typo, the source order, the wrong source, or a missing import |
| "Can't use type string" | Cast it: field::number (see String Columns Need Casts) |
Aggregate expressions are not allowed in where:; use having:`` | Filter a measure with having: |
| 20+ random errors | Backtick a reserved word (`date`, `hour`, `number`); see Reserved Words |
Can't find field 'X' to set access modifier | An include {} sits before the extend { rename: }. Rename first, then include {} naming the field by its new name (see Field Management) |
IO Error: No files found that match the pattern "data/x.csv" | A data-file path problem, not the model. See Relative Data-File Paths. The "not defined" errors under it are cascade |
| "Can't find source X", or an import path error | The path is relative to the importing file: import "orders.malloy" from the same folder, import "../orders.malloy" from a subfolder. Subfolders are fine; do not move files to fix an import |
| Circular imports | Source A imports B which imports A. Restructure to break the cycle |
unexpected 'from' | from() was removed. Write the query directly: source: x is q extend {...}, or source: x is (q -> {...}) extend {...} |
| Query-based source: "Can't find field" | The source query's group_by and aggregate fields must match what extend {} references; check imported sources exist |
| "Cannot redefine 'X'" | See Cannot Redefine Query-Based Source Columns |
sum(items.cost) fails with Join path is required for this calculation | Over a join_many path write items.cost.sum(). Over a join_one path both compile: sum(o.total) counts each order once per base row, o.total.sum() once per order. Use the second for the joined source's own total |
order_by on a joined path fails | Alias the field in group_by (yr is races.year) and order by the alias |
When in doubt, backtick it. Unquoted reserved words cause cascading errors on unrelated lines.
// WRONG // RIGHT
dimension: d is Date::date dimension: d is `Date`::dateWords most likely to appear as column names:
date, time, day, month, year, quarter, week, hour, minute, second,
number, string, boolean, type, table, source, index, count, sum, avg, min, max,
true, false, null, is, on, with, all, from, by, in, to, for, select, order_by,
top, bottom, desc, asc, row, range, current, window, ranknumber: only the bare word needs backticking; account_number is finesource: reserved; use a different alias like traffic_sourceis not null// AVOID (compiles with a warning) // RIGHT
dimension: is_sold is sold_at != null dimension: is_sold is sold_at is not null!= null also compiles and returns the right rows (a null row comes back false), but the compiler warns Use 'is not null' to check for NULL instead of '!= null'. Write is not null to keep the file warning-free.
// WRONG: day_of_week is a function // RIGHT
dimension: dow is created_at.day_of_week dimension: dow is day_of_week(created_at)Property access: .month, .year, .quarter, .day, ::date
Function call required: day_of_week(), week(), hour(), minute(), second()
.date Is a Cast, Not a TruncationCalendar truncations are .day, .week, .month, .quarter, .year (plus .hour, .minute, .second for timestamps). .date is not among them: it's a cast (::date), not a truncation, so created_at.date does not compile. This bites twice: once at compile time, and again as a latent bad #(doc) comment that only a review pass catches ("truncated to date" is a doc smell; it should say "to day").
// WRONG // RIGHT
created_at.date created_at.day // truncate to day
created_at::date // cast to a dateunit(start to end), and the unit decides the operand typeAn interval is unit(start to end). Two rules, both enforced by the compiler:
days(a - b) fails with Can not offset time by 'date'. The to form is the only one.days(@2020-01-01 to now) compiles. A date column does not: days(signup_date to now) fails with Cannot measure from date to timestamp. Cast the odd one out (::date, ::timestamp). now is a timestamp.Which units accept what:
| Units | Kind | Operands |
|---|---|---|
seconds, minutes, hours, days | clock | timestamps or dates |
weeks, months, quarters, years | calendar | dates only: on timestamps they fail with Cannot measure interval using 'month' for 'timestamp' values; calendar interval measurement requires dates |
// WRONG: subtraction, and a calendar unit applied to timestamp columns
dimension: gap is days(closed_at - opened_at)
dimension: months_open is months(opened_at to closed_at)
// WRONG: approximating a calendar unit that exists
dimension: months_open is days(opened_at to closed_at) / 30.44
// RIGHT
dimension: days_open is days(opened_at to closed_at)
dimension: months_open is months(opened_at::date to closed_at::date)The calendar units are real and exact. If one fails, read the message: it is telling you to cast the operands, not to divide by 30.44.
nullif// WRONG // RIGHT
a / b a / nullif(b, 0)// WRONG: "Can't use type string" // RIGHT
measure: avg_score is avg(score) measure: avg_score is avg(score::number)Dirty columns: null the sentinel before casting. ::number is a strict cast, so a column that carries non-numeric sentinels ('NA', 'N/A', '', '-', 'null') compiles fine but fails at query time with Could not convert string 'NA' to DOUBLE. Strip the sentinel with nullif first, then cast (aggregates skip nulls):
// WRONG: throws on 'NA' at query time // RIGHT: nulls 'NA', then casts
measure: s is avg(score::number) measure: s is avg(nullif(score, 'NA')::number)Chain nullif for multiple sentinels: nullif(nullif(score, 'NA'), '')::number. Sample the column's values first (run: source -> { group_by: score; limit: 20 }) to see which sentinels it uses.
// WRONG // RIGHT
count() { where: complaint = 'true' } count() { where: complaint = true }Check schema: if BOOL, use true/false. If STRING, use 'true'/'false'.
greatest() / least() Are Null-PoisoningMalloy's greatest() / least() return NULL if any argument is null, unlike Postgres GREATEST/LEAST, which ignore nulls. Porting a LookML/SQL expression verbatim is a silent parity bug: the number just goes null for any row with a missing input. Coalesce the result back to a non-null argument:
// WRONG: one null input nulls the whole thing
dimension: last_touch is greatest(email_at, call_at)
// RIGHT: fall back so a null arg can't poison the result
dimension: last_touch is greatest(email_at, call_at) ?? email_at ?? call_atThere is no scalar median, and PERCENTILE_CONT cannot be expressed as a measure in this build. Every documented form for a custom SQL aggregate - percentile_cont!(x, 0.5), sql_number(...), sql_number(...) { is_aggregate: true }, and the # is_aggregate annotation - resolves as a scalar and fails with "Cannot use a scalar field in a measure declaration." The docs' own avg_dist example fails the same way. This is a deployed-runtime limitation, not a syntax error you can fix: do not burn cycles trying !, sql_number, or is_aggregate variations to get a median.
// DOES NOT COMPILE in this build (all forms resolve as scalar):
measure: median_x is percentile_cont!(x, 0.5)
measure: median_x is sql_number("PERCENTILE_CONT(...) ...") { is_aggregate: true }To see a median or any percentile as evidence (for example before choosing a tier boundary), run the two-stage nearest-rank query in skill:malloy-discover § Example Queries. It is a query result you read, not a reusable measure.
For a measure, ship avg instead, or defer median with a documented gap ("median deferred: no scalar median / runtime rejects raw-SQL aggregates"). Tell the user; don't silently substitute avg for a metric that was specified as median.
stddev does work, so reach for it when the question is about spread. It is a native Malloy aggregate rather than a raw-SQL escape, so unlike everything above it compiles both inline and as a measure:, and it is the sample standard deviation. variance, stddev_samp, and stddev_pop are not Malloy functions, and pushing them through ! fails as a scalar exactly like percentile_cont!.
// WORKS: inline, or as a measure on a source
run: order_items -> { aggregate: sd is stddev(sale_price) }
source: items is order_items extend { measure: price_stddev is stddev(sale_price) }extend {} and include {}, in that orderMalloy has two field-management mechanisms for base sources. include {} is the curated default; extend { except / accept / rename } handles the renames. They do compose, but only in one order: the extend {} that renames must come before the include {}, and include {} must name the field as it is after the rename.
| Mechanism | Where it lives | Keywords | Experimental flag? |
|---|---|---|---|
| Access modifiers (default) | include {} | public: / internal: / private: | Yes (##! experimental.access_modifiers) |
| Field management | extend {} | accept: / except: / rename: | No |
include {} for documented, curated base sourcesUse include {} whenever the source doesn't need a rename:. It's the only way to attach #(doc) tags to raw columns, and it's the canonical way to hide empty/garbage/duplicate columns (internal:) and sensitive ones (private:). See skill:malloy-model § Access Modifiers.
##! experimental.access_modifiers
source: orders is conn.table('orders') include {
public:
#(doc) Order identifier
order_id
#(doc) Customer who placed the order
user_id
internal:
raw_payload_json // empty after JSON extraction
legacy_status_code // superseded by status_code
}rename: is needed: rename first, then include {}The usual reason is a collision inside include {}: a measure cannot share a name with a raw column, even one tagged internal:, and the compiler says so (Cannot redefine 'revenue'). The fix is to rename the raw column out of the way, which frees the name for the measure. Order is what makes it work:
##! experimental.access_modifiers
// RIGHT: rename frees `revenue`, include curates what is left, measure takes the name
source: orders is conn.table('orders')
extend { rename: raw_revenue is revenue }
include {
#(doc) Revenue as loaded, before adjustments
internal: raw_revenue
public: order_id, user_id
}
extend { measure: revenue is raw_revenue.sum() }Two ways to get the order wrong, with the errors they produce:
include {} before the renaming extend {} fails with Can't find field 'X' to set access modifier, currently surfaced as an internal compiler error. include runs against names that no longer exist by the time the rename is applied.include {} fails with `revenue` not found. After a rename only the new name exists; use it.You do not have to give up include {} to get a rename: the curated surface, #(doc) on raw columns, and the public/internal/private tiers all survive. Renaming the measure instead is still worth considering when the raw column name is the one people know, but it is a modeling preference, not a workaround for a limitation.
extend {} clauses (reference)accept:: allow-list, keep only the named columnsexcept:: deny-list, drop the named columns; keep everything else (mutually exclusive with accept:)rename:: alias a raw column to free up its original name for a measure or dimensionconn.sql() to conn.table() + Malloy clausesThe biggest reason teams reach for conn.sql() is column gating, aliasing, and per-row derivation in one place. All three have native equivalents:
run: <source> -> { select: *; limit: 1 } to discover all columns. Anything in the table but not in the SQL's SELECT was being intentionally hidden, so preserve that gating.conn.table('…').include { internal: ... } (lets you also #(doc) the public columns). A rename: in the same source does not force you off include {} - see item 4 for the order.extend { rename: ... } before include {}, naming the field by its new name in include {} (they compose, but only in that order). If the alias was to free up a name for a measure, use rename: raw_X is X, then measure: X is raw_X.sum().dimension: definitions in extend {}.WHERE: source-level where:.Columns from table -> { group_by, aggregate } or conn.sql() already exist. You cannot re-declare them.
// WRONG: "Cannot redefine 'user_id'"
source: facts is conn.table('t') -> { group_by: user_id, aggregate: total is sum(amt) }
extend { dimension: user_id is user_id }
// RIGHT: add only NEW derived dimensions
source: facts is conn.table('t') -> { group_by: user_id, aggregate: total is sum(amt) }
extend { dimension: is_high_value is total > 1000 }To add #(doc) tags to existing query columns, use include {} between the query and extend.
// WRONG: "Cannot redefine 'overview'" when sales already declares view: overview
source: wines is sales extend { view: overview is { aggregate: record_count } }
// RIGHT: give the extension its own name
source: wines is sales extend { view: summary is { aggregate: record_count } }An extension adds to the parent's namespace, it does not override it. This bites when you extend a source to "replace" one of its views: rename the new definition, or edit the view on the parent source instead of extending it. Malloy reports the same Cannot redefine 'X' for dimensions and measures that collide with an inherited name, per the sections above and below.
conn.sql() When Malloy Has a Native Pattern// WRONG: raw SQL for pre-aggregation
source: facts is conn.sql("""SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id""")
// RIGHT: Malloy query-based source
source: facts is conn.table('orders') -> { group_by: user_id, aggregate: total is sum(amount) }Mandatory: call search_malloy_docs before reaching for conn.sql(). Don't argue from intuition. Most patterns that look SQL-only have a Malloy equivalent, including the ones reviewers historically said couldn't be expressed.
| Looks like it needs SQL | Malloy equivalent |
|---|---|
| Multi-CTE pipeline | Stacked query-based sources: source: a is t -> {...}; source: b is a -> {...}; source: c is b -> {...} |
| UNNEST / array column access | An array of records is read by its field path: group_by: ys.yr, aggregate: tv is ys.v.sum(). An array of plain values is read with .each and nothing after it: group_by: v is arr.each. arr.each.field does not exist (data types docs) |
| PIVOT (conditional aggregation) | Filtered aggregates: aggregate: a is x.sum() { where: cat = 'a' }, b is x.sum() { where: cat = 'b' } |
| Window functions (any frame, including custom) | calculate: with sum_cumulative, lag, lead, rank, row_number, avg_moving, first_value, last_value: supports partition_by: and order_by: (window functions docs) |
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING | sum_cumulative(x) - x (cumulative-including-current minus current = cumulative-excluding-current) |
WHERE date = (SELECT max(date) FROM …) (latest snapshot) | join_cross to a one-row aggregate source, then filter on the joined max_date field |
| Multi-key joins | join_one: x is target on a = x.a and b = x.b and c = x.c |
greatest() / least() / CASE chains | All native: greatest(a, b, c), least(a, b), pick 'x' when cond else 'y' |
| Dialect-specific scalar functions | function_name!return_type(args): Malloy's raw-SQL function escape (no conn.sql() block needed) |
Genuinely valid conn.sql() candidates (rare):
MERGE patterns)conn.sql()Never use conn.sql() for: simple column selection or renaming, WHERE filters, two-table joins, column type casts, latest-snapshot patterns, conditional aggregation, or window functions of any kind.
If a project's standards file specifies a stricter policy (e.g., a search_malloy_docs rationale comment requirement above every conn.sql() block), defer to that.
workingDirectoryduckdb.table('data/x.csv') is resolved against the DuckDB connection's workingDirectory, not
against the model file. Publisher sets that to the package root, so relative paths work there with
no config at all. Every other host reads it from malloy-config.json, and a relative value there
is resolved against the process's current directory - not the config file's directory, and not the
VS Code workspace root. canonicalizeConfigPath in malloy-db-duckdb/src/duckdb_config.ts calls
canonicalizePath with no baseDirectory, which is a bare path.resolve(input).
So the same config works or fails depending on which directory the editor or shell was launched from. That is why this breaks intermittently and appears to be a model bug.
// WRONG: resolved against the process cwd, so it works from the repo root and
// fails from anywhere else - including however your editor happened to launch
{"connections": {"duckdb": {"is": "duckdb", "workingDirectory": "malloy"}}}
// RIGHT: absolute, so the cwd cannot change the answer
{"connections": {"duckdb": {"is": "duckdb", "workingDirectory": "/abs/path/to/pkg"}}}Point it at the directory the model's table paths are written relative to - the package root, the
one holding data/. Keep the model's paths relative (data/x.csv) so Publisher still serves it;
only the config carries the absolute path.
The symptom, and the cascade. One IO Error at the source: line, then a "not defined" error
for every field of that source:
line 87: IO Error: No files found that match the pattern "data/product_usage.csv"
line 91: 'org_slug' is not defined
line 93: Reference to undefined value active_usersThose field errors are not real. Fix the first error and they all go. Do not start renaming columns.
Check the config before the model when a source that Publisher queries fine fails in the editor or CLI with a missing-file error. The model is the same file; the resolution base is not.
// RIGHT: .json works like .csv/.parquet
source: reviews is duckdb.table('data/reviews.json')
// RIGHT: newline-delimited JSON is read the same way
source: events is duckdb.table('data/events.ndjson')
// RIGHT: read options need read_json_auto in a SQL source
source: nested is duckdb.sql("""SELECT * FROM read_json_auto('data/reviews.json')""")
// WRONG: shelling out to python, or converting to CSV firstDuckDB reads JSON directly, so never preprocess a .json file before modeling it and never reach for a scripting language to inspect one. Both a top-level array of objects and newline-delimited JSON work through duckdb.table().
Quirk: JSON carries no schema, so a value written as "90" arrives as a string where the same data in CSV would be inferred as a number. Cast it in the source, under a new name (reusing the column's own name is a redefinition error):
source: reviews is duckdb.table('data/reviews.json') extend {
dimension: points_num is points::number
}.xlsx In Place, Never Convert// RIGHT when the sheet is a plain table (header in row 1, data under it, no blank row inside
// it): read it where it sits, like .csv/.parquet (in a Publisher package the sandbox
// connection is `duckdb`)
source: budget is duckdb.table('data/budget.xlsx')
// RIGHT for anything messier. Profile the top rows first to find the real header row and the
// last real column, because nothing else will tell you where they are. Put the probe in the
// model file as its own source: Publisher refuses raw SQL in an ad-hoc query.
// SELECT * FROM read_xlsx('data/sales.xlsx', sheet = 'Sales Data',
// range = 'A1:Z15', header = false, all_varchar = true)
source: sales is duckdb.sql("""
SELECT * FROM read_xlsx('data/sales.xlsx',
sheet = 'Sales Data', -- EDIT: only the first sheet is read by default
header = true,
range = 'A5:J100000' -- EDIT: A5 is the real header row. Keep the column bound at the
) -- last real column; the row bound just has to clear the end.
WHERE "Order ID" LIKE 'SO-%' -- EDIT, REQUIRED: a data-row predicate. This is what ends the
""") -- read; drop it and every empty row in the range comes back.
// WRONG: converting the spreadsheet to Parquet or CSV first (an unnecessary extra step)Do not convert spreadsheets before modeling. DuckDB's excel extension reads .xlsx directly and loads automatically on first use, so a sheet that is a plain table needs nothing more than duckdb.table(). Converting does not avoid any of the problems below, it just moves them into a copy that goes stale the next time someone updates the workbook.
Plenty of real exports are not plain tables, and nothing tells you. A report title, a "generated on" banner, a merged group header, a blank line above the header, or a blank spacer row inside the data are all ordinary, and none of them is visible from Malloy. There is no error either: the package loads, the server reports serving, the query returns 200, and the number is just wrong. So make two checks before building on the read: compare aggregate: record_count is count() against what you know is in the file, and select: *; limit: 1 to see what the columns really are. If either disagrees with the file, the read is wrong and so is every measure over it.
table() takes a plain file path only, so anything needing read_xlsx options (sheet, range, header, ignore_errors, normalize_names, all_varchar, empty_as_varchar, stop_at_empty) goes through the SQL-source form.
Quirks:
sheet = 'Name'. There is no function that lists a workbook's sheet names, but passing one that does not exist reports a suggestion (Sheet "x" not found ... Did you mean: "Notes"), which is one way to find a name you were not given.range that starts at the real header row.range, stop_at_empty defaults to true and the read stops at the first blank row, which on a real sheet is usually a spacer between blocks rather than the end of the data: a 30-row sheet with one spacer after row 10 reads as 10 rows. stop_at_empty = false lifts that, but it only helps when the header really is in row 1; with a title above the header you need the range anyway, and a range flips the default for you. It also hands the blank rows back as all-null rows, so the count comes out one high per spacer until you filter them.range reads every cell inside it, so an overshot bound manufactures padding: past the last real column you get all-null fields (A5:Z100000 on a ten-column sheet yields 26, the extras named C10 and _1 through _15), and past the last real row all-null rows (A5:J100000 on a 1,500-row sheet reads 99,995). Spacers, subtotals, and footnotes come through as rows too. So the row filter is not tidying-up, it is the thing that ends the read: filter to what a data row looks like (WHERE "Order ID" LIKE 'SO-%') rather than to IS NOT NULL, which keeps any footnote carrying text in the first column. A bound that falls SHORT of the data is the dangerous direction: the rows and columns past it are dropped with no error at all, so overshoot the row bound and let the filter end the read.$1,234, 12% and N/A are all text: a text cell in that first row makes the whole column a string (on one real export, all ten of them), while a text cell further down leaves the column numeric and makes the read throw instead (Could not convert string ... to DOUBLE). ignore_errors = true fixes that second case, nulling the bad cells and keeping the column a number. It does nothing for the first.run: source -> { group_by: shape is replace(raw_col, r'[0-9]', '9'); aggregate: n is count(); order_by: n desc } collapses every value to its format and counts it, so on one real price column the 16 euro-denominated rows surface beside the 1,484 in dollars. A plain group_by raw_col; limit: 20 sorts lexicographically, which hides exactly the shapes that matter.::number throws on the first bad cell. try_cast(regexp_replace("Total Revenue", '[^0-9.-]', '', 'g') AS double) nulls what it cannot read instead of failing and is right for a plain $1,234.56, but it is not a general parser. It concatenates every digit in the cell, so 1,234 (see tab 2) becomes 12342. It understands only a leading ASCII -, so an accounting (1,234), a Unicode minus and a CR suffix all come back positive, while a trailing - (1,234-) comes back null and drops the row from the sum. And it assumes . is the decimal point, so a European 1.234,56 comes back a thousandfold small. Handle the shapes your sample actually found, and divide a percent by 100. Failure is quiet either way: a cast that fails on every row sums to 0 rather than erroring, and a text date strips to a number rather than a null ('01/02/2023' becomes 1022023).WHERE "Customer Name" = 'TOTAL' finds it and WHERE "Order ID" = 'TOTAL' returns nothing, and an empty result reads as a pass. Do not run the total through the same expression, because a wrong sign survives a row count, survives select: *, and cancels out when both sides are parsed the same broken way.header = false.normalize_names = true for snake_case names.all_varchar = true hands back each cell's stored value as text, so a date arrives as its raw Excel serial number rather than a date: '44929' from a sheet Excel wrote, '44927.0' from one DuckDB's own xlsx writer wrote, and '44929.5' where the cell carries a time of day. Which form you get depends on the tool that wrote the file, so do not detect serials by matching for an integer; try_cast(... AS double) accepts all three and returns null for a cell that was stored as text ('01/02/2023'), which is the test you want. Convert with date '1899-12-30' + floor(try_cast(d AS double))::int, not from 1900-01-01. Both wrappers earn their place: adding a double to a date does not compile, and a bare ::int rounds, so an afternoon timestamp would land on the next day.CASE WHEN try_cast(d AS double) IS NOT NULL THEN date '1899-12-30' + floor(try_cast(d AS double))::int ELSE try_strptime(d, '%m/%d/%Y')::date END. Without all_varchar, a uniformly date-formatted column arrives as real date and timestamp values, and a stray text cell behaves exactly as the typing rule above says. Note what ignore_errors = true does here: it nulls that cell rather than parsing it, so the hand-typed date is lost silently.run: source -> { group_by: pk_field, aggregate: n is count(), having: n > 1, limit: 10 }Symptoms: sum() returns astronomical values. Causes: event tables, batch retries, merged sources.
Joining an aggregate-grain source (a decade/month/region summary table) into a detail-grain source produces values that do not respond to the query's filters. Malloy's symmetric aggregates prevent fan-out; they cannot prevent this, because the joined value is unfiltered by construction: it was computed over the whole population before the query ran.
run: track_analysis -> {
where: genre = 'Rock'
group_by: decade
aggregate: track_count // filtered: Rock only -> 701
group_by: decade_trends.decade_track_count // unfiltered population -> 1,088
}Two count-shaped numbers side by side, one filtered and one not; read as "701 of 1,088 Rock tracks" it is simply wrong: 1,088 is every genre. Two legitimate resolutions:
energy_vs_decade). Then every joined field's #(doc) must say it is a fixed population value that does not respond to filters, and count-shaped fields with no comparison purpose (like decade_track_count) should be internal:; they only invite the misreading.This is the modeling-time consequence of ignoring skill:malloy-define's scope advice to skip pre-aggregated snapshot tables and compute fresh in Malloy instead.
Before writing a pick expression or filtered measure with a numeric cutoff, see skill:malloy-model § Key Rules: every boundary must be user-supplied, distribution-derived (query the percentiles first: Malloy has no percentile function, so use the Tier boundaries query in skill:malloy-discover), or explicitly flagged as an assumption in its #(doc). Never invent one silently.
except: Removes Fields From Namespace Entirelyexcept: in include {} completely removes fields: dimensions and measures cannot reference excluded fields. Use internal: instead when derived dimensions need the raw column.
// WRONG: dimension references excluded field
source: x is conn.table('t')
include { except: raw_date }
extend { dimension: order_date is raw_date::date } // ERROR! raw_date is gone
// RIGHT: internal fields are still available in extend
source: x is conn.table('t')
include { internal: raw_date }
extend { dimension: order_date is raw_date::date } // WorksMalloy compiles top-to-bottom. Define lookup/dimension tables before the source that joins them, or use import statements in multi-file projects.
Call search_malloy_docs BEFORE first use of any of these. Don't guess the syntax:
pick expressionscalculate)percentile or statistical functions: but see the hard limit above, raw-SQL aggregates (sql_number / is_aggregate / percentile_cont!) do not compile as measures in this build; there is no scalar median (stddev is the exception and does work as a measure)days(), months()): always unit(start to end), and calendar units need date operands (see above)source: x is (q -> {...}) extend {...}; from() was removed and no longer parses)! operator / sql_number()© malloydata, MIT. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file
Just SKILL.md in skills/malloy-gotchas-modeling of malloydata/publisher.
Open the folder on GitHubat commit b9a1a19
Malloy Gotchas Modeling next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.
| Skill | Stars | Used in | Tokens | Auto-check | Licence | Repo updated |
|---|---|---|---|---|---|---|
| Malloy Gotchas Modeling this skillmalloydata/publisher | 116 | — | ~8.7k | Automated safety check: Pass | MIT | |
| Add Compiler Diagnostic Fixdotnet/roslynator | 3.5k | — | ~1k | Automated safety check: Pass | Custom licence | |
| Fixalirezarezvani/claude-skills | 28k | 1 repos | ~765 | Automated safety check: Pass | MIT | |
| OmniRoute Model Catalogdiegosouzapw/OmniRoute | 74k | — | ~589 | Automated safety check: Pass | MIT | |
| Model Bank Metadatalobehub/lobehub | 83k | — | ~2k | Automated safety check: Pass | Custom licence | |
| Harness Threat Modelruvnet/ruflo | 74k | — | ~363 | Automated safety check: Notes | MIT |
dotnet/roslynator
A skill your agent uses when adding a Roslynator code fix for C compiler error CS or RCF, editing Diagnostics.xml or CodeFixes.xml, or when compiler-diagnostic-fixes-testing.md shows…
alirezarezvani/claude-skills
Fix failing or flaky Playwright tests. An agent skill from alirezarezvani/claude-skills.
diegosouzapw/OmniRoute
Looks up which AI models an OmniRoute gateway can reach, creates or updates model aliases and tests whether individual models respond.
lobehub/lobehub
Fills and maintains the knowledgeCutoff, family and generation fields on model cards in LobeHub's model bank, from a single new model up to repo-wide backfills.
ruvnet/ruflo
Enterprise-review-grade threat model from harness threat-model <path.
diegosouzapw/OmniRoute
Lists and manages AI models from the OmniRoute command line: browse a provider's catalog, search it, and add, edit, remove or test-add models.
malloydata/publisher
Score one analytical answer against a verified golden, and score which of the entities the golden depends on retrieval delivered to the answerer.
malloydata/publisher
Fix a CRITICAL Trivy finding that is failing CI in this repo (a vulnerability, misconfiguration, or secret from security-scan.yml or image-scan.yml), or add, review, or retire an entry in…
malloydata/publisher
Turn a list of questions into an eval set, whatever shape it arrived in: a JSONL a customer sent, a CSV, a spreadsheet export, a markdown doc, an email thread, or a pull from production logs.
malloydata/publisher
Conduct a local Publisher evaluation loop in five steps: scrape/run, eval, diagnose, improve, checkpoint.
malloydata/publisher
Make the smallest safe Malloy model edit that closes a diagnosed model-owned gap, with a probe receipt for every factual claim.
malloydata/publisher
Decide whether ONE answer matches its golden, and say whether you believe the golden.
Malloy modeling mistakes and compile-error fixes. An agent skill from malloydata/publisher. Malloy Gotchas Modeling is an agent skill from malloydata/publisher. Malloy modeling mistakes and compile-error fixes.
Run `npx skills add malloydata/publisher --skill malloy-gotchas-modeling -a claude-code`. Or copy the skill folder (skills/malloy-gotchas-modeling in malloydata/publisher) into .claude/skills/malloy-gotchas-modeling in your project. Claude Code loads it when a task matches its description.
Run `npx skills add malloydata/publisher --skill malloy-gotchas-modeling -a codex`. Or copy the skill folder (skills/malloy-gotchas-modeling in malloydata/publisher) into .agents/skills/malloy-gotchas-modeling in your project. Codex loads it when a task matches its description.
Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add malloydata/publisher --skill malloy-gotchas-modeling -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/malloy-gotchas-modeling, .gemini/skills/malloy-gotchas-modeling, .github/skills/malloy-gotchas-modeling and .opencode/skills/malloy-gotchas-modeling in your project.
SKILL.md names no scripts, command-line tools or credentials: Malloy Gotchas Modeling is instructions for the agent only.
SKILL.md names 1 domain. As links in the text: docs.malloydata.dev. This is read from the text; nothing was executed.
Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.
Malloy Gotchas Modeling is published under the MIT licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.
About 8.7k tokens (SKILL.md is roughly 35k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.
Skills that share tags, products or a category with Malloy Gotchas Modeling: Add Compiler Diagnostic Fix (dotnet/roslynator, 3.5k stars), Fix (alirezarezvani/claude-skills, 28k stars), OmniRoute Model Catalog (diegosouzapw/OmniRoute, 74k stars) and Model Bank Metadata (lobehub/lobehub, 83k stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.
malloydata (a GitHub organization) maintains it in malloydata/publisher, which has 116 GitHub stars. The repository holds 29 skills in this directory. The repository was last updated on October 9, 2026.
Source: malloydata/publisher on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.