Placing one language inside another is one of those anti-patterns that seems harmless until you’re trying to lint it, test it, or ask an LLM to reason about it. The moment you embed a language inside a string in another language, its entire toolchain disappears.

We contain multitudes. Our code should be singular, not polyglot.

Introduction Link to heading

For most of software’s history, we had an unspoken skill: reading shell scripts embedded in YAML while simultaneously holding the YAML structure in your head. We called this experience. It was mostly suffering.

Thirty lines of Bash nested in a CI pipeline, SQL queries wrapped in triple-quoted Python strings, RUN instructions in Dockerfiles that had quietly evolved into small programs — all of it was invisible to every tool we had. The linter couldn’t see it. The formatter couldn’t touch it. The test suite couldn’t reach it. And when it broke, it broke at runtime, in production, at the worst possible moment.

We accepted this as normal. We hired senior engineers who were good at holding complexity in their heads. We called it “understanding the stack.” It was workaround culture dressed up as expertise.

Then agentic AI arrived, and the cost of this pattern became impossible to ignore.

Every language has a toolchain — a set of tools built specifically to understand, validate, and transform code written in that language. ShellCheck for Bash. sqlfluff for SQL. hadolint for Dockerfiles. These tools are the accumulated knowledge of a language community, encoded into automated checks. The moment you embed a language inside a string in another language, you opt out of that toolchain entirely.

No linting. No formatting. No unit tests. No syntax checking. No refactoring support. No AI assistance.

That last one is new. And it matters.

a nasty surprise

The hidden cost your team is paying Link to heading

There’s a version of this anti-pattern that’s easy to spot: a forty-line Bash script crammed into a GitHub Actions YAML file, indented four spaces, quietly failing in ways that only surface when someone finally reads the raw logs. That’s the obvious case.

The less obvious case is the ten-line script that grows to forty over eighteen months, because it started reasonable. It always starts reasonable.

The real cost isn’t the lines of code. It’s the toolchain you gave up when you wrote the first line:

  • Linting: ShellCheck catches entire classes of shell bugs — unquoted variables, command injection risks, portability issues — that you will never catch in code review. You only get ShellCheck if the code lives in a .sh file.
  • Testing: You cannot unit test a step in a GitHub Actions YAML. You can test a shell script with bats. The distinction matters when that script handles deployments, secrets rotation, or data migrations.
  • Security review: Raw SQL in strings is invisible to static analysis tools that scan for injection vulnerabilities. SQL in .sql files, or through a typed query layer, is visible. That’s not a small difference.
  • Onboarding: New engineers can’t contribute to code they can’t navigate with their tools. The senior engineer who wrote the YAML-embedded script in 2022 left. The engineer who inherited it has no idea what it does, because their IDE treats it as a string.

Each of these costs is individually small. Collectively, they compound into a maintenance tax your team pays silently, forever.

Examples Link to heading

Bash inside YAML Link to heading

The most common offender is shell script embedded in CI pipeline configuration:

# Anti-pattern
- name: Build and deploy
  run: |
    GL=$(command -v gitleaks || echo "")
    if [ -z "$GL" ]; then
      echo "gitleaks not found"
      exit 1
    fi
    "$GL" git --pre-commit --staged
    hugo --minify
    rsync -avz public/ user@host:/var/www/

This looks manageable. It will grow. It always grows. Once it’s thirty lines long, it’s untestable, unlintable, and invisible to everything except the engineer brave enough to read raw YAML at 11pm when the deployment is failing.

The fix takes ninety seconds:

# Better
- name: Build and deploy
  run: ./scripts/build-and-deploy.sh

The script in scripts/build-and-deploy.sh now gets ShellCheck on every commit, can be unit tested with bats, shows up in IDE search results, and is a first-class citizen of the repo rather than a YAML value buried four levels of indentation deep.

SQL inside application code Link to heading

Raw SQL embedded as strings is one of the most common versions of this anti-pattern, and one of the most consequential:

# Anti-pattern
def get_user(user_id):
    query = """
        SELECT id, name, email FROM users
        WHERE id = %s AND deleted_at IS NULL
        ORDER BY created_at DESC
    """
    return db.execute(query, (user_id,))

The SQL is invisible to any SQL-aware tooling. Syntax errors surface at runtime. There’s no formatting, no query analysis, and no linter pointing at the ORDER BY on a query that returns one row. More critically: security tooling that scans for SQL injection patterns has nothing to work with. The query is just a string.

Extracting queries to .sql files — or to a typed query layer like SQLAlchemy, Prisma, or sqlc — restores the toolchain and makes the data access layer a thing your team can actually reason about. It also means your next security audit has something to work with.

Shell inside Dockerfiles Link to heading

RUN instructions follow the same pattern as YAML pipelines. A Dockerfile that accumulates logic in multi-line RUN commands is a shell program that can never be tested:

# Anti-pattern
RUN apt-get update && \
    apt-get install -y curl jq && \
    curl -fsSL https://get.example.com/install.sh | bash && \
    jq --version

The hadolint tool lints Dockerfiles and also runs ShellCheck on RUN instructions — but only on simple ones. For anything beyond a few chained commands, extract to a script:

COPY scripts/install-deps.sh /tmp/
RUN chmod +x /tmp/install-deps.sh && /tmp/install-deps.sh

Now the script is testable, lintable, and visible in your IDE. The Dockerfile is readable. The build is debuggable.

The general rule Link to heading

The pattern generalises to any combination: HTML in JavaScript template literals, JSON in environment variable strings, regular expressions as unadorned strings, Markdown in Python docstrings used as config, Lua in nginx config files.

The test is simple: can the dedicated toolchain for that language see this code? If not, it’s embedded, and the quality layer is gone.

The fix is always the same: extract into a file of the right type and let the toolchain do its job. Use it as an opportunity to improve the extracted code — add tests, add linting, add pre-commit hooks. Code that lives in its natural file type gets treated like code. Code embedded in a string gets treated like a value.

Why this matters more now than it did a year ago Link to heading

Here is the shift that makes this a CTO-level conversation: your team is probably already using AI coding tools. GitHub Copilot, Cursor, Claude Code — pick one. These tools work by reading the signals your existing toolchain produces: linter output, type errors, test failures, IDE diagnostics.

Embedded code produces none of those signals.

An LLM working on a shell script buried in your CI YAML is working exactly as blind as a developer would be if their editor showed everything as plain text. The syntax highlighting, the ShellCheck annotations, the test results — none of it exists. The model falls back to pattern-matching on raw text, which is the same position your junior engineers are in when they first encounter the file.

You are investing in AI tooling to make your engineers more productive. Every piece of code embedded inside a string in another language is a direct tax on that investment.

This was always a code quality issue. It is now also an AI productivity issue. The two arguments together are usually enough to move teams that have been comfortable with the status quo.

Making the change Link to heading

The good news is that this anti-pattern is one of the easiest to address incrementally. You do not need a big refactor or a dedicated sprint. The playbook is straightforward:

  1. Stop adding new embedded code. This costs nothing. Add it to your code review checklist or your pre-commit hooks.
  2. Extract when you touch. Any time an engineer modifies embedded code, they extract it as part of the same PR. Boy scout rule applied surgically.
  3. Add the toolchain immediately. When a script moves to a .sh file, add ShellCheck to the pre-commit pipeline that day. The value shows up instantly — often catching bugs that have been hiding in the original embedded version for months.

Teams that have done this report the same experience: the scripts get better the moment they are visible. Not because the engineers got better overnight, but because the tools — which were always there, always capable — finally had something to work with.

Further reading Link to heading

  • ShellCheck — static analysis for shell scripts; the first tool you recover when you extract Bash from YAML
  • bats-core — unit testing framework for Bash
  • sqlc — generates type-safe Go and Python from .sql files
  • hadolint — Dockerfile linter that also runs ShellCheck on RUN instructions
  • sqlfluff — SQL linter and formatter; works on .sql files and supports fifteen dialects