PyTorch LSTMs and ONNX export have a long, mutually frustrating history. The shape of the bug has been the same since 2019: declare your sequence length as a dynamic axis, run torch.onnx.export, and either the export crashes with a RuntimeError about control flow, or it succeeds but produces a model that gives wrong outputs at inference. PyTorch issues #24235 and #33495 have been open about this since the original report.

The fix is counter-intuitive: don't make seq_len dynamic. Keep it static at export time, declare it as a fixed dimension, and the export becomes trivial. This article shows why, and what to do if you actually need variable-length inference.

The bug, in one paragraph

PyTorch's LSTM module internally uses control flow to iterate over the time dimension. When you tell ONNX export "the time dimension is dynamic," it has to capture that loop as a runtime Loop op in the ONNX graph. The capture process uses torch.jit.script, which struggles with LSTM internals — either failing outright with a RuntimeError, or producing a working-but-wrong model that gives different outputs from the eager PyTorch version. Static seq_len avoids the loop capture entirely: the LSTM is unrolled at export time, producing a clean fixed-shape graph.

The static-seq_len pattern

working LSTM export
import torch import torch.nn as nn class PriceLSTM(nn.Module): def __init__(self, input_size=1, hidden=64, layers=2): super().__init__() self.lstm = nn.LSTM(input_size, hidden, layers, batch_first=True) self.fc = nn.Linear(hidden, 1) def forward(self, x): out, _ = self.lstm(x) return self.fc(out[:, -1, :]) model = PriceLSTM().eval() # Sequence length 120 — STATIC at export dummy = torch.randn(1, 120, 1) # (batch=1, seq=120, features=1) torch.onnx.export( model, dummy, "eurusd_lstm.onnx", input_names=["input"], output_names=["output"], opset_version=17, dynamic_axes={"input": {0: "batch"}, # ONLY batch dynamic "output": {0: "batch"}}, do_constant_folding=True, )

Key point: dynamic_axes only contains {0: "batch"}. Dimension 1 (sequence) is fixed at 120, dimension 2 (features) is fixed at 1. The LSTM gets unrolled at export, the graph is purely static-shape, no control flow needed.

The wrong way (this is the bug)

dynamic_axes={"input": {0: "batch", 1: "seq"}} — declaring seq_len dynamic. This is what trips the LSTM export. Don't do it.

Matching this on the MQL5 side

Since seq_len is now fixed at 120 in the model, your MQL5 code uses the same constant:

corresponding MQL5 setup
#define SAMPLE_SIZE 120 const long input_shape[] = {1, SAMPLE_SIZE, 1}; OnnxSetInputShape(ExtHandle, 0, input_shape); matrixf input(1, SAMPLE_SIZE); // must match exported shape

The 120 in MQL5 has to match the 120 used during export. If they diverge — for example, you change the EA to use 60 bars but forget to re-export the model — OnnxRun will fail.

If you really need variable-length

Genuine variable-length inference is rare in retail trading — most EAs decide on a fixed lookback window. But if you need it, two patterns work:

Pattern A: pad to maximum length

Train and export the LSTM with a fixed maximum sequence length (say, 240). At inference time in MQL5, pad shorter sequences with zeros up to 240 and pass them through. The LSTM still processes the padded values, but if your model was trained with padding masks, it learns to ignore them.

Pattern B: export multiple models

If you only need a few discrete lengths (e.g., 60, 120, 240 for different timeframes), export one ONNX file per length. Load all three sessions in MQL5 and route inputs to the matching session. Wasteful in memory but simple and bug-free.

Pattern C: use the new TorchDynamo exporter

The newer exporter (dynamo=True) has better control-flow support and sometimes succeeds where the legacy one fails. Try this only as a last resort, validate carefully — correctness has been hit-or-miss across PyTorch versions.

Validating the result

Always run a comparison between PyTorch and ONNX outputs after export. For LSTMs specifically, this catches the silent-correctness bug where export "succeeded" but the model is wrong:

validate_lstm.py
import numpy as np import torch, onnxruntime as ort x = torch.randn(1, 120, 1) y_torch = model(x).detach().numpy() sess = ort.InferenceSession("eurusd_lstm.onnx") y_onnx = sess.run(None, {"input": x.numpy()})[0] diff = np.abs(y_torch - y_onnx).max() print(f"Max abs diff: {diff:.2e}") assert diff < 1e-4, "LSTM export is broken!"

If max-diff is above 1e-4, the export silently went wrong. Re-check that you used the static-seq_len pattern, that model.eval() was called, and that opset is 17.