# sql-template-strings keeps the placeholders and the values in one template

> sql-template-strings is a small tagged template tag that turns an ES6 template literal into an object both mysql and postgres understand, with text, sql and values fields computed for you. It removes the bookkeeping of numbered placeholders, and it leaves raw identifiers, where the safety argument is entirely yours.

**felixfbecker/node-sql-template-strings** — ES6 tagged template strings for prepared SQL statements 📋

- Repository: https://github.com/felixfbecker/node-sql-template-strings
- Stars: 625 · Forks: 39
- Language: JavaScript
- License: ISC
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/felixfbecker-node-sql-template-strings

## A tag that returns an object, not a string

The library is one tagged template function. You import it, tag a template literal with it, and what you get back is not a string:

```js
const SQL = require('sql-template-strings')
```

The How it works section states it plainly: the SQL template string tag transforms the template string and returns an object that is understood by both mysql and postgres. The example then shows the three fields that object carries:

```js
const query = SQL`SELECT author FROM books WHERE name = ${book} AND author = ${author}`
typeof query // => 'object'
query.text   // => 'SELECT author FROM books WHERE name = $1 AND author = $2'
query.sql    // => 'SELECT author FROM books WHERE name = ? AND author = ?'
query.values // => ['harry potter', 'J. K. Rowling']
```

The design decision is that one object carries both placeholder dialects. text is the PostgreSQL style with numbered parameters, sql is the question mark style, and values is the array of substitutions. A driver picks the field it understands, and the caller never has to know which dialect the library is targeting.

That is why the package is described as working with mysql, mysql2, postgres and sequelize. The README shows the equivalence for each of them, with the mysql form using question marks and an array, the postgres form using $1 and $2 and an array, and the sequelize form using question marks with a replacements option. In every case the tagged version replaces the hand written placeholder string.

The comparison the README draws is fair. The hand written form is not wrong, it is just two lists to keep in step.

## Why the INSERT with many columns is the real motivation

The README makes its argument with an insert rather than a select, and that is the right choice of example.

The awkward version lists seven placeholders and then a seven element array in the same order:

```js
db.query(
  'INSERT INTO books (name, author, isbn, category, recommended_age, pages, price) VALUES (?, ?, ?, ?, ?, ?, ?)',
  [name, author, isbn, category, recommendedAge, pages, price]
)
```

The tagged version puts the values next to the column they belong to:

```js
db.query(SQL`
  INSERT
  INTO    books
          (name, author, isbn, category, recommended_age, pages, price)
  VALUES  (${name}, ${author}, ${isbn}, ${category}, ${recommendedAge}, ${pages}, ${price})
`)
```

The column list and the value list are now adjacent, and adding a column means editing one place. The README also notes that template strings support line breaks while normal strings do not, which is what lets the query above be formatted across several lines.

The mechanism behind that formatting is worth understanding, because it explains the whole design. A tag function receives the literal string parts and the interpolated values as separate arguments, so it can see where each value belongs and number the placeholders itself. That is exactly the information a hand written query string throws away.

The cost of the approach is that the query becomes a runtime object rather than a constant string, so anything in your codebase that expects a plain string, such as a query log formatter or a tool that inspects SQL text, has to know about the object.

## append() removes the placeholder index bookkeeping

The second feature is append(), and it is aimed at the case where a query is assembled in pieces.

The method takes another query or a plain string, and it returns this for chaining:

```js
query.append(SQL`AND genre = ${genre}`).append(' ORDER BY rating')
query.text   // => 'SELECT author FROM books WHERE name = $1 AND author = $2 AND genre = $3 ORDER BY rating'
query.sql    // => 'SELECT author FROM books WHERE name = ? AND author = ? AND genre = ? ORDER BY rating'
query.values // => ['harry potter', 'J. K. Rowling', 'Fantasy'] ORDER BY rating
```

Note what happens to the numbering. Appending a fragment does not mean renumbering the placeholders you already had and shifting the values array to match. The library owns that bookkeeping, which is what the README means when it says this allows you to build complex queries without having to care about the placeholder index or the values array.

The conditional case is where it earns its keep:

```js
const query = SQL`SELECT * FROM books`
if (params.name) {
  query.append(SQL` WHERE name = ${params.name}`)
}
query.append(SQL` LIMIT 10 OFFSET ${params.offset || 0}`)
```

The fragment is only added when the condition holds, and the placeholder that follows it is still numbered correctly, because the tag that produced the fragment numbered its own parameter in isolation and append merged the two.

The one wrinkle is in the last line of the first example, where the values array in the README's comment has the trailing ORDER BY rating text attached to it. That is a documentation slip rather than behaviour, since the appended plain string carries no value, but it is a reminder that the README examples are illustrative rather than executable transcripts.

## Raw values move the escaping responsibility to you

Some values cannot be replaced by placeholders in prepared statements, and the example given is a table name. That is the case where the library deliberately stops helping.

The README explains that append() replaces the SQL.raw() syntax from version 1, so you now just pass a string and it gets appended raw:

```js
const table = 'books'
const order = 'DESC'
const column = 'author'

db.query(SQL`SELECT * FROM "`.append(table).append(SQL`" WHERE author = ${author} ORDER BY ${column} `).append(order))
```

And then it tells you what you have taken on. When inserting raw values, you are responsible for quoting and escaping them with proper escaping functions first if they come from user input, with mysql.escapeId() and pg.escapeIdentifier() named as the tools for that:

```js
// escape user input manually
mysql.query(SQL`SELECT * FROM `.append(mysql.escapeId(someUserInput)).append(SQL` WHERE name = ${book} ORDER BY ${column} `).append(order))
pg.query(SQL`SELECT * FROM `.append(pg.escapeIdentifier(someUserInput)).append(SQL` WHERE name = ${book} ORDER BY ${column} `).append(order))
```

There is a second warning in the same passage, and it is the more technical one. Executing many prepared statements with changing raw values in a loop will quickly overflow the prepared statement buffer and destroy their performance benefit, so you have to be careful.

That is a subtle failure mode: the code keeps working and keeps returning correct results, while the performance advantage of prepared statements disappears. Raw identifiers are not free even when they are correctly escaped.

## Arrays bind through ANY in Postgres only

Binding a list as a single parameter is a Postgres feature, and the README is precise about the limit:

```js
const authors = ['J. K. Rowling', 'J. R. R. Tolkien']
const query = SQL`SELECT name FROM books WHERE author = ANY(${authors})`
query.text   // => 'SELECT name FROM books WHERE author = ANY($1)'
query.values // => ['J. K. Rowling', 'J. R. R. Tolkien']
```

The array arrives as a single value in the values list, and the ANY construct on the database side turns it back into a set comparison. The heading makes the restriction clear: to bind the array dynamically as a parameter use ANY, PostgreSQL only.

That is a portability edge rather than a gap. The same template works against mysql, but the ANY syntax is not something mysql understands, so the query you write with an array is not the query you can run everywhere. Which is worth knowing before you pick the library for a codebase that talks to more than one database.

The general principle the section illustrates is that the tag will not change the semantics of your SQL. It renumbers placeholders and hands you the values; what the database does with them is still your SQL's business.

## Named statements and the sequelize binding switch

Postgres can name prepared statements, which lets the database reuse the parsing and other work, provided the SQL behind the name stays the same and only the parameters change.

The old way passes an object with a name, a text field and a values array:

```js
// old way
pg.query({name: 'my_query', text: 'SELECT author FROM books WHERE name = $1', values: [book]})
```

With the tag, the name is set by a method:

```js
// with template strings
pg.query(SQL`SELECT author FROM books WHERE name = ${book}`.setName('my_query'))
```

The README also notes that you can set the name property on the statement object directly, or use Object.assign().

Sequelize has its own wrinkle. By default, Sequelize escapes replacements on the client, so the values are interpolated into the string before it leaves your process. To switch to using a bound statement in Sequelize, you call useBind(), and the boolean parameter defaults to true. The section is cut off mid sentence in the copy available here, so the exact shape of the call is not spelled out, but the switch itself is named and the default is given.

That distinction matters for anyone who cares about whether a value is sent to the database as a bound parameter or embedded in the query text. The library lets you choose per driver, and in the sequelize case you have to choose explicitly.

## Releases from 2016, a package version ahead of its tags

The licence is ISC, which is a short permissive licence and leaves no obligations beyond keeping the notice. That makes the code easy to vendor into a service that already has its own licensing rules.

The release history needs a note. The recent tags are v2.1.1 on 2016-08-10, v2.1.0 with the title Sequelize Support on the same day, and v2.0.0 on 2016-05-03. The version in package.json is 2.2.2, so the published package is ahead of the last tagged release, which is a normal consequence of a semantic-release workflow where tags are cut from the commit history.

The last push was on 2026-10-22 and the repository is not archived. Given that the code itself has not changed in a decade, what moves is the dependency tree, and the repository is set up for that. It carries a renovate.json for automated dependency updates, a semantic-release script for versioning, a commitizen configuration and a validate-commit-msg hook, plus Travis for continuous integration, mocha for tests, nyc for coverage, eslint and prettier for style, and typedoc for the API documentation that the README links to at a surge.sh address.

The engine requirement in package.json is node >=4.0.0, which tells you the code is written to run on very old Node versions. The devDependencies list old majors of mocha, eslint, prettier and sequelize, which is consistent with a project whose source has been stable for years.

For an engineer picking this up today, the honest summary is that the library is small, stable and unlikely to break, while its surrounding tooling is old and its README has a few rough edges, such as the truncated sentence in the sequelize section.

## Conclusion

sql-template-strings suits Node services that build SQL by hand and want placeholders that cannot drift out of sync with the values array, especially when the same query has to run against mysql and postgres. It is the wrong tool if you want an ORM, if your queries never need dynamic fragments, or if you are unwilling to own identifier escaping. Verify first that the driver you use is one of the four named in the README, since the shape of the returned object is what makes the tag work, and read the raw value section before you interpolate a table or column name, because the library deliberately leaves that to you.

## FAQ

### What does the sql-template-strings tag return?

It returns an object rather than a string, with a text field using PostgreSQL numbered placeholders, a sql field using question mark placeholders, and a values array holding the substituted values. Both mysql and postgres drivers understand that object.

### How do I insert a table name that cannot be a placeholder?

Pass it to append() as a plain string, which is inserted raw and replaces the SQL.raw() syntax of version 1. The README asks you to escape any such value yourself with mysql.escapeId() or pg.escapeIdentifier() when it comes from user input.

### Can sql-template-strings bind an array of values?

Yes, but only with the ANY construct in PostgreSQL, so the query becomes for example SQL`SELECT name FROM books WHERE author = ANY(${authors})`. The array is passed as one parameter, and the ANY syntax does not exist in mysql.

### How do I use named prepared statements with sql-template-strings?

Call the setName() method on the statement, for example SQL`SELECT author FROM books WHERE name = ${book}`.setName('my_query'). The README also mentions setting the name property directly on the statement object or using Object.assign().

## Sources

- [felixfbecker/node-sql-template-strings on GitHub](https://github.com/felixfbecker/node-sql-template-strings)
- [Issues](https://github.com/felixfbecker/node-sql-template-strings/issues)
- [License: ISC](https://github.com/felixfbecker/node-sql-template-strings/blob/master/LICENSE)
- [README](https://github.com/felixfbecker/node-sql-template-strings/blob/master/README.md)
- [Releases](https://github.com/felixfbecker/node-sql-template-strings/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/felixfbecker-node-sql-template-strings
