Skip to content
Go back

The Apache Ossie Power BI Converter, in Detail

The Apache Ossie Power BI Converter, in Detail

Ossie ↔ Power BI

Part three of this series listed Power BI as the largest thing Apache Ossie did not have. Not just missing a converter, but missing at the syntax level: no DAX in the spec’s dialect enum, and two pull requests that were open and not merged.

They merged. On 30 September, timed with FabCon Europe, Christian Wade posted Microsoft’s commitment to Apache Ossie on the Power BI blog, alongside Snowflake’s engineering post covering the same work from the other side. The stated focus is “cross-platform semantic-layer conversion without moving or duplicating the underlying data,” and the shipped scenario is one Ossie document converted into both a Snowflake Semantic View and a Power BI semantic model.

One piece of vocabulary, since it causes confusion. A Power BI model can be written as TMSL, a single JSON file, or as TMDL, a folder of text files. They are two serializations of the same Tabular model, not two formats with different capabilities, so everything below applies equally to both. The converter emits TMSL by default and TMDL through output_format, which routes through Microsoft’s TOM library and so needs the [tom] extra and a .NET runtime.

This is a detailed look at what that converter actually does. Not a tutorial: the converters README covers the model format and the Microsoft converter’s own README covers usage and the translation table, and both are good. This is the behaviour around the edges of those documents, which is where the decisions that affect your model actually live.

A note on timing. Apache Ossie is an incubating project and this converter is weeks old. The spec sits at 0.2.0.dev0, there is no PyPI release, and several of the rough edges below are the kind that get fixed in a single pull request. So treat none of it as permanent. The point is not to grade an unfinished thing, it is to show what you can actually build with it today, and what you have to work around to get there. Everything here was run against converters/microsoft at commit a5bdbbc in October 2026.


1. Expressions, and the SQL to DAX boundary

Power BI evaluates measures and calculated columns only as DAX. Ossie expressions are per-dialect and usually SQL. So every expression hits a boundary on the way in, and how it fares depends entirely on which dialect you wrote.

Write DAX and there is no boundary

A DAX dialect is used exactly as written, at any complexity. CALCULATE, variables, time intelligence, whatever you like: the converter does not inspect it, does not simplify it, and does not try to understand it. It wins over any SQL dialect in the same metric, in any order.

The flip side is that nothing checks it either. An invalid DAX expression converts just as happily as a valid one, and stays invalid until something compiles it, which as section 6 covers is further away than you would expect.

So the short recommendation for anything non-trivial: write the DAX yourself and skip the rest of this section.

Use SQL and there are four caveats

Only a short list of shapes translates. One aggregate over one column, COUNT(*), one aggregate divided by another with NULLIF(x, 0) absorbed into DIVIDE(), or string concatenation. SUM(order_amount) + 1 is already too far. The README’s table covers the aggregates but not the division.

Every column must resolve to exactly one dataset. DAX has no bare column reference, and the name in your SQL is the physical sourceColumn while DAX addresses a column by its model name. So COUNT(*) is on the supported list and still fails in an entirely ordinary two-table model, because it cannot tell which table’s rows you meant to count.

Calculated columns are stricter than measures. For a field, the translator handles only string concatenation. Anything else, including UPPER(customer_name), becomes BLANK().

A refusal still gives you a model that loads. BLANK() is valid DAX, so the import succeeds and the card renders empty. The refusal is at least loud, naming the field and the reason, and --strict turns any report into a non-zero exit. A successful translation, by contrast, says nothing at all. In both cases the original expression is kept as an annotation, so you can see what it started as.

Which SQL dialect you use barely matters. The converter prefers ANSI_SQL, then falls back to DATABRICKS, SNOWFLAKE or BIGQUERY and translates those the same way, so a model written for Snowflake converts without an ANSI_SQL dialect at all.

Coming back

There is no translator in the other direction. DAX returns as DAX, and a converter for a SQL platform looking for DATABRICKS or ANSI_SQL finds nothing and drops the metric.


2. What each format can hold that the other cannot

This asymmetry is larger than I expected, and it runs almost entirely one way.

Going from Ossie to Power BI, exactly one concept has nowhere to live: a field’s label, which is Ossie’s display name for it. The converter drops it, reporting that a Power BI semantic model has nowhere to record it. (datatype is also not applied, though that is Power BI inferring the type from the DAX rather than a gap.)

Coming back from Power BI to Ossie, fourteen do:

ScopeNo Apache Ossie counterpart
Modelrow-level security roles, perspectives, translations and linguistic metadata, shared Power Query expressions and parameters, data source definitions, Power Query group folders
Tablehierarchies, calculation groups, incremental refresh policy, detail rows definition
Columndate table variations, sort-by-column
MeasureKPIs, detail rows definition

Worth noticing what sits in that table: translations and linguistic metadata, which is Power BI’s own mechanism for display names. Both formats have a notion of a display name, and neither can read the other’s.

None of these are thrown away. Each is preserved under custom_extensions as a JSON string, which is what makes a Power BI round trip lossless. In the Ossie document it looks like this, here at the top level of a model imported from Fabric:

custom_extensions:
- vendor_name: POWER_BI
  data: '{"_v": 2, "culture": "en-US", "expressions": [...], "annotations": [...],
          "defaultPowerBIDataSourceVersion": "powerBI_V3"}'

That POWER_BI entry is what the converter itself calls the stash, in its warnings and in its code, and it turns up in every section from here on. Worth noticing the last key in it, because section 6 comes back to it: the flag that makes a model importable at all is kept in here too. But a stash is vendor-private by definition: no other converter reads it. So everything in that table is safe if the model is going home, and invisible the moment it is going anywhere else.

It is the same rule that decides what happens to your measures. A DAX dialect is at least readable by anything that looks; a POWER_BI stash is readable by Power BI and nothing else. Whether this converter preserves your work depends less on what you wrote than on where the model is going next.


3. Relationships

Ossie relationships are deliberately plain: from, to, from_columns, to_columns. Power BI relationships carry cardinality, cross-filter direction and an active flag. Reconciling those turns out to be the most carefully handled thing in the package.

Composite keys do not survive. A Power BI relationship joins exactly one column pair, so an Ossie relationship with multiple columns is skipped with a warning. Ossie supports composite joins; Power BI does not.

A relationship written fresh in Ossie gets no cardinality and no cross-filter direction, so Power BI’s defaults apply. The Ossie schema has no field for either one, so the converter has nothing to carry across.

The two ends can get swapped on the way in. Ossie requires from to be the many side and to to be the one side. Power BI has no such rule and lets you declare a relationship either way round. So when it meets a Power BI relationship declared one-to-many, the converter swaps the two ends to satisfy Ossie, and records flipped in the stash. On the way back out it swaps them again, and your original model is reproduced exactly.

Edit a relationship and the remembered cardinality is thrown away. The stash holds the cardinality from the original Power BI model. If you change which columns the relationship joins, that remembered value is describing a join that no longer exists, so the converter discards it and lets Power BI’s defaults apply instead. Putting it back would be the worse choice: a stale cardinality does not fail, it quietly changes numbers.


4. Writing a model, and the decisions it makes for you

Your source line chooses the storage mode. A source that parses as a table reference gives you a Direct Lake partition. A source that is a query gives you an import partition instead, warning Direct Lake cannot read a query source; using an import partition. There is no setting for this, and a model that came from Power BI keeps its original partitions out of the stash, so only generated partitions follow the rule.

It matters because Direct Lake cannot hold a calculated column, meaning any field whose expression is not a plain column reference. So whether a field is fine or fatal depends on a line you probably wrote for an unrelated reason. The cleanest answer is to compute those fields in a view over the source table, so every platform reads the same value instead of each dialect’s own expression.

Name the home table for every metric. Ossie metrics sit at the top level and carry no owning table, while Power BI measures live on one. Given nothing to go on, the converter puts the measure on your first dataset and tells you so: no home table recorded; the measure is placed on 'customers'. A revenue measure over orders ends up filed under customers. It still evaluates correctly, but anyone opening the model will look for it in the wrong place. Neither README mentions the fix, which is a vendor extension on the metric itself:

metrics:
- name: total_revenue
  expression: ...
  custom_extensions:
  - vendor_name: POWER_BI
    data: '{"table": "orders"}'

A mistyped relationship key costs you the join. Misspell from and the model converts, imports, and quietly has no relationship in it. The warning is table 'None' is not in the model; relationship skipped, which names a table you never wrote. An unrecognised key reads as missing rather than as a mistake, so the message describes the symptom instead of the typo that caused it.


5. Export and import are not mirrors

It is tempting to read a bidirectional converter as symmetric. It is not, and the asymmetry is structural rather than accidental.

Export translates, import does not. Export turns SQL into DAX where it can. Import has no DAX to SQL translator, and given row context and filter context there is a good argument that it should not have one.

A Power BI model survives the trip out and back unchanged. Import then export gives a byte-identical file. This is what the stash is for.

An Ossie model does not. Every metric comes back with DAX as its only dialect. A hand-written ANSI_SQL expression does not return to dialects, it sits in the stash as an annotation instead.

So the converter round-trips whichever model is native to the platform it passed through, and demotes the one that is not.


6. Validation, and why none of it proves the model works

The package ships three validators, and the distinction between them is the most useful thing in this post.

Two of them run offline. validate_tmsl and validate_bim go through Microsoft’s TOM library, so they need the [tom] extra, a .NET runtime, and the assemblies restored by scripts/restore_tom.py. They check model structure and object references, and the docstring is explicit that they do not validate DAX.

One of them is real. validate_with_engine is, in its own words, “the only validation in this package that leaves the local machine.” It deploys a semantic model into a workspace, refreshes it, evaluates it, and deletes it again.

Neither offline check catches the most common blocker. Fabric only imports a semantic model that is marked as a V3 model, and that marking is a single model-level property, defaultPowerBIDataSourceVersion. Building a model from an Ossie file, the converter never sets it, so Fabric rejects the upload:

Import from JSON supported for V3 models only

Nothing warns you before that point. The conversion exits 0 with no warnings, and validate_tmsl reports the model as valid, because the missing property says nothing about whether the model is well formed. It only matters to Fabric’s import pipeline, which is not what either of them is checking.

Setting the property on the converted model is enough to fix it. A model that came from Power BI already carries it, because it was saved into the stash on the way in and written back on the way out. It is missing only when you start from an Ossie file of your own.

And DAX is never checked on your machine at all. Even once Fabric accepts the file, a measure the converter wrote is not known to compile until the engine compiles it, and not known to return the right number until something evaluates it.


What this adds up to

The difficult parts are the ones that work. Translating between two languages that share no vocabulary, and refusing rather than guessing when the translation is not obvious. Keeping everything Ossie cannot express, so a Power BI model comes home unchanged. Discarding a remembered cardinality once you have edited the join it described. All three are decisions somebody thought about, and all three would have been easy to get wrong in a way that produces wrong numbers instead of errors.

What is missing is smaller and quieter. A key that makes the file importable. A note that your source line just chose your storage mode. A warning that every measure landed on the wrong table. None of them are hard problems, and each of them costs you an afternoon the first time you meet it.

For a converter a few weeks old that is a fair place to be, and these are the kind of gaps that close quickly. Worth knowing where the line sits today, though, because “define once, reuse anywhere” is a claim about exactly this ground: a converted model is not an importable model, an importable model is not a validated one, and nothing you can run on your own machine tells you which of the three you are holding.


Share this post on:

Next Post
What's Still Missing From Apache Ossie, and Where This Goes From Here