malloydata/publisher/malloy-gotchas-modeling
Claude Code and agent skill by malloydata/publisher
- community
Checking that the SKILL.md file is still in the repository.
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.
Install
npx vskill@latest install malloydata/publisher/malloy-gotchas-modelingSKILL.md
Modeling Gotchas
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.
When a Model Will Not Compile
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 |
Reserved Words: Backtick Them
When in doubt, backtick it. Unquoted reserved words cause cascading errors on unrelated lines.
Words most likely to appear as column names:
number: only the bare word needs backticking;account_numberis finesource: reserved; use a different alias liketraffic_source
NULL Checks: write 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.
Date Functions vs Properties
Property access: .month, .year, .quarter, .day, ::date Function call required: day_of_week(), week(), hour(), minute(), second()
.date Is a Cast, Not a Truncation
Calendar 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").
Interval Functions: unit(start to end), and the unit decides the operand type
An interval is unit(start to end). Two rules, both enforced by the compiler:
- Never subtract.
days(a - b)fails withCan not offset time by 'date'. Thetoform is the only one. - Mixing a date and a timestamp needs a cast, unless the date side is a literal. A date literal widens to a timestamp on its own, so
days(@2020-01-01 to now)compiles. A date column does not:days(signup_date to now)fails withCannot measure from date to timestamp. Cast the odd one out (::date,::timestamp).nowis 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 |
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.
Safe Division: Always nullif
String Columns Need Casts for Aggregates
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):
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.
Boolean Columns: No Quotes
Check schema: if BOOL, use true/false. If STRING, use 'true'/'false'.
greatest() / least() Are Null-Poisoning
Malloy'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:
No Scalar Median; Raw-SQL Aggregates Don't Compile
There 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.
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!.
Field Management: extend {} and include {}, in that order
Malloy 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 |
Default: include {} for documented, curated base sources
Use 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 tidy away empty/garbage/duplicate columns (internal:, which a caller's query can still read through its own extend or include) and to block sensitive ones (private:). See skill:malloy-model § Access Modifiers.
When a rename: is needed: rename first, then include {}
This is the start of the file. Read the full SKILL.md on GitHub.
Security
- Security scanPassed, 100 of 100Pattern checks on the skill's files.
- Model reviewNot runA model review is scheduled for the next rescan.
Quality evaluation
- Task evaluationNot runNo evaluation yet. Run one in Skill Studio.
Source
- SKILL.md in the repositoryCheckingskills/malloy-gotchas-modeling/SKILL.md
Popularity
- Repository stars
- 95
- Repository forks
- 23
- 7-day trend score
- 23
Stars and forks are for the whole GitHub repository, not this skill alone. Not refreshed yet.
Works with
Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.
- Claude Code
- Cursor
- GitHub Copilot
- Windsurf
- Codex
- Gemini CLI
- Cline
- Amp
- Roo Code
- Goose
- Continue
- Kilo Code
- Junie
- Trae
- OpenHands
- Qwen Code
- AI Agent
- Tabnine Chat
- Sourcegraph Cody
- Amazon Q
- Firebase Genkit
- Sweep
- Devin
- Phind
- Mentat
- Claude Desktop
- Aider
- GPT Pilot
- Plandex
- bolt.new
- v0
- Replit Agent
- Anse
- TypingMind
- BetterChatGPT
- LibreChat
- Warp AI
- Pieces
- Fig AI
Embed badge
[](https://verified-skill.com/skills/malloydata/publisher/malloy-gotchas-modeling)