download the code iconIn the SQL Server community, most examples of dynamic T-SQL code are plagued with a severe readability problem. They're hard to read because dynamic T-SQL code isn't formatted, so you can't easily grasp the command and query structure. Concatenation operators and the necessary doubling of single quotes obfuscate the dynamic query even more. And sometimes several parts of the query need to be generated from the same variable, which ends up adding even more concatenations. Tracing the query to see what was generated isn't much help because the query isn't formatted.

Related: T-SQL Best Practices, Part 1

The unreadability of dynamic T-SQL code discourages DBAs and developers from using it in large T-SQL solutions. Plus, taking advantage of free solutions contributed by the SQL Server community is hampered because people can't easily grasp the code generated.

To make dynamic T-SQL code more readable, I've been using several techniques since the introduction of the nvarchar(max) data type in SQL Server 2005. This data type is vital to my techniques because string size is no longer a limitation to query formatting, and text manipulation is as easy as with other nvarchar() data types.

The CompareRows stored procedure in Listing 1 illustrates my techniques. It compares rows from two tables, from two queries, or from one table and one query, then lists the nonmatching records.

  1. CREATE PROC CompareRows
  2.   @ColList nvarchar(max)
  3. , @Srctab AS sysname = ''
  4. , @TgtTab AS sysname = ''
  5. , @SrcQry nvarchar(max) = ''
  6. , @TgtQry nvarchar(max) = ''
  7. , @SrcWhereClause nvarchar(max) = ''
  8. , @TgtWhereClause nvarchar(max) = ''
  9. , @SrcDB AS sysname = ''
  10. , @TgtDB AS Sysname = ''
  11. , @SrcInstance AS sysname = ''
  12. , @TgtInstance AS sysname = ''
  13. AS
  14. BEGIN
  15.   DECLARE @sql nvarchar(max)
  16.   SET @sql =
  17.   '
  18.   WITH SrcRows AS
  19.   (
  20.   <SrcQry>
  21.   )
  22.   , tgtRows As
  23.   (
  24.   <TgtQry>
  25.   )
  26.   , UnionOfDataSetsToCompare as
  27.   (
  28.   Select "(source) [<SrcInstance>].[<SrcDB>].<SrcTab>" as DataSetId
  29.   , <ColList>
  30.   FROM SrcRows
  31.   UNION ALL
  32.   Select "(target) [<TgtInstance>].[<TgtDB>].<TgtTab>" as DataSetId
  33.   , <ColList>
  34.   FROM TgtRows
  35.   )
  36.   SELECT
  37.     MAX(DataSetId) as DataSetid
  38.   , <ColList>
  39.   FROM UnionOfDataSetsToCompare
  40.   GROUP BY
  41.     <ColList>
  42.   HAVING MAX(DataSetId) = MIN(DataSetId)
  43.   ORDER BY
  44.     <ColList>
  45.   '
  46.   -- Assume some behavior for missing parameters.
  47.   IF @SrcDB = '' SET @SrcDB = DB_NAME() -- Current database if no @SrcDb
  48.   IF @TgtDB = '' Set @TgtDB = @SrcDB -- Same database if no @tgtDg
  49.   IF @TgtTab = '' Set @TgtTab = @Srctab -- Same table name if no @TgtTab
  50.   IF @TgtWhereClause = '' SET @SrcWhereClause = @TgtWhereClauseAnchor
  51.   -- BEGIN CALLOUT A
  52.   IF @SrcQry <> '' SET @sql = REPLACE (@sql, '<SrcQry>', @SrcQry)
  53.   -- If @SrcQry is supplied, the following will result in no changes
  54.   -- because the <SrcQry> tag will be gone.
  55.   SET @sql =
  56.       REPLACE
  57.       (
  58.       @sql,
  59.         '<SrcQry>',
  60.         '
  61.         SELECT
  62.           <ColList>
  63.         FROM [<SrcInstance>].[<SrcDB>].<SrcTab>
  64.         <SrcWhereClause>'
  65.         )
  66.   IF @TgtQry <> '' Set @sql = REPLACE (@sql, '<TgtQry>', @TgtQry)
  67.   -- If @TgtQry is supplied, the following will result in no changes
  68.   -- because the <TgtQry> tag will be gone.
  69.   Set @sql =
  70.       REPLACE
  71.       (
  72.       @sql,
  73.       '<TgtQry>',
  74.       '
  75.       SELECT
  76.         <ColList>
  77.       FROM [<TgtInstance>].[<TgtDB>].<TgtTab>
  78.       <TgtWhereClause>'
  79.       )
  80.   -- END CALLOUT A
  81.   -- Replace tags.
  82.   SET @sql = REPLACE (@sql, '<ColList>', @ColList)
  83.   SET @sql = REPLACE (@sql, '<SrcTab>', @Srctab)
  84.   SET @sql = REPLACE (@sql, '<TgtTab>', @Tgttab)
  85.   SET @sql = REPLACE (@sql, '<SrcWhereClause>', @SrcWhereClause)
  86.   SET @sql = REPLACE (@sql, '<TgtWhereClause>',@TgtWhereClause)
  87.   SET @sql = REPLACE (@sql, '<SrcDB>', @SrcDB )
  88.   SET @sql = REPLACE (@sql, '<TgtDB>', @TgtDB )
  89.   -- Remove the linked server syntax part if not specified.
  90.   IF @SrcInstance = '' SET @sql = REPLACE (@sql, '[<SrcInstance>].', '')
  91.   SET @sql = REPLACE (@sql, '<SrcInstance>', @SrcInstance
  92.   IF @TgtInstance = '' Set @sql = REPLACE (@sql, '[<TgtInstance>].', '')
  93.   SET @sql = REPLACE (@sql, '<TgtInstance>', @TgtInstance )
  94.   -- Replace double quotes with real single quotes.
  95.   SET @sql = REPLACE (@sql, '"', '''')
  96.   PRINT @sql -- Show the query for debugging purposes.
  97.   EXEC (@sql) -- Execute the query.
  98. END

After I explain how this stored procedure works, I'll walk you through the techniques it uses to make the dynamic T-SQL code easier to read.

How CompareRows Works

As the CompareRows stored procedure demonstrates, comparing rows can be done very efficiently using a UNION ALL statement to put together rows from a source and a target, provided that you add the DataSetId column in which the values identify the source or target. A common table expression named UnionOfDataSetsToCompare unites rows from the source and target, adding the DataSetId value. A GROUP BY clause is then applied on the columns being compared and the HAVING clause compares minimum and maximum aggregates on the generated DataSetId column to detect which rows have no matching counterpart in the source or target. The end result is a rowset of all rows that have no matching record, with the DataSetId column describing where each row is from.

The stored procedure compares the data in the rows for the columns you specify. It has 11 parameters, most of which are optional because CompareRows is flexible when it comes to specifying the source and target rows to compare. Rows can come from the same table or different tables, from the same database or different databases, or from the same SQL Server instance or different instances. You can also filter out subsets of rows to compare by including WHERE clauses that apply to the source or target table. Rows can even be generated by queries.

Here are the stored procedure's 11 named parameters:

  • @ColList -- Used to specify which columns to include in the comparison. The primary key columns must be included. The columns' names need to be in a comma-separated list.
  • @SrcTab -- Used to specify the name of the source table. The schema must be included. (Add brackets if required.)
  • @TgtTab -- Used to specify the name of the target table if it isn't the same as the source table. The schema must be included.
  • @SrcQry -- Used to specify the source query. When used, @SrcTab is ignored and the source rows come from this query. The database and schema must be included in the FROM clause.
  • @TgtQry -- Used to specify the target query. When used, @TgtTab is ignored and the target rows come from this query. The database and schema must be included in the FROM clause.
  • @SrcWhereClause -- Used to provide the WHERE clause for the source rows if applicable.
  • @TgtWhereClause -- Used to provide the WHERE clause for the target rows if applicable.
  • @SrcDB -- Used to specify the source database if it isn't the current database.
  • @TgtDB -- Used to specify the target database if it isn't the same as the source database.
  • @SrcInstance -- Used to specify the linked source server if it isn't the local server.
  • @TgtInstance -- Used to specify the linked target server if it isn't the local server.

Listing 2 shows an example of how to use CompareRows to compare the Contacts table in the AdventureWorks database on one instance against the same table in a restored copy of the AdventureWorks database on another instance. As you can see, there's no need to specify the target table because it has the same name as the source table. The same applies for target database.

  1. EXEC CompareRows
  2.   @ColList = 'ContactID, NameStyle, Title,
  3.   FirstName, MiddleName, LastName,
  4.   Suffix, EmailAddress, EmailPromotion,
  5.   Phone, PasswordHash, PasswordSalt,
  6.   rowguid, ModifiedDate'
  7.   , @Srctab = 'Person.Contact'
  8.   , @SrcDb = 'AdventureWorks'
  9.   , @TgtDb = 'AdventureWorksCopy'
  10. .., @SrcInstance = 'ASQL9'
  11. .., @TgtInstance = 'ASQL10'

Listing 3 shows an example of how to use the stored procedure to compare the rows generated from two queries. You can download CompareRows by clicking the download link at the top of this page.

  1. EXEC CompareRows
  2.     @ColList = 'ContactID, NameStyle, Title,
  3.      FirstName, MiddleName, LastName,
  4.      Suffix, EmailAddress, EmailPromotion,
  5.      Phone, PasswordHash, PasswordSalt,
  6.      rowguid, ModifiedDate'
  7.   , @SrcQry = 'SELECT * FROM
  8.      ASQL9.AdventureWorks.Person.Contact
  9.      WHERE phone LIKE "440%"'
  10.   , @TgtQry = 'Select * FROM
  11.      ASQL10.AdventureWorksCopy.Person.Contact
  12.      WHERE phone LIKE "440%"'

How to Make Dynamic T-SQL Code Easier to Read

I use several techniques to improve the readability of dynamic T-SQL code. They include giving T-SQL strings an indented query structure, using replacement tags, using double quotes, and replacing a set of tags with other tags.

Give T-SQL strings an indented query structure. In T-SQL, a literal string might start with the opening quote on one line and end many lines later with the closing quote. Every line change (i.e., line feed or new line) in between becomes part of the string. You can dynamically add more line changes to an existing T-SQL string by using the CHAR or NCHAR function with the parameter of 10, which is the ASCII value for a line feed. This generates a line feed that both the SQL Server T-SQL parser and SQL Server Management Studio (SSMS) interpret and present as a line change. The immediate advantage of this technique is that the code is much more readable because it retains the line changes and indents.

However, SSMS limits PRINT output to 8,092 characters. To work around this limit, I created the Tools.PrintSqlCode stored procedure, which you can find in the file. In that file, you'll also find the Tools.SqlCodeLinesInResultSet function, which is more suitable when you want to log the generated code in a work table. Both split the code at line changes, making multiple code lines.

Use replacement tags. To avoid concatenations, I use replacement tags, which are similar to XML element tags. The replacement tags' names match those of the named parameters, so it's easier to understand what the code is doing. Then, at the end of the code, I use T-SQL's REPLACE string function to modify the query in the dynamic T-SQL code. The REPLACE function is handy because you can include extra code or logic in its parameters if needed. For example, you can include code that translates a date into string or use a CASE statement to conditionally generate the code that will replace the tag.

Let's look at a simple example. Dynamic T-SQL code that backs up a database would traditionally look something like this:

  1. DECLARE @sql nvarchar(max)
  2. SET @sql = 'Backup database ['+@MyDb+']
  3. TO disk = ''c:\backups\'+@MyDb+'.bak''
  4. WITH description =
  5. ''Backup of '+@MyDb+ ' at ' +
  6. CONVERT (nvarchar, getdate() 121)+ ''''

To make it more readable, you can use <dbName> instead of @MyDb and <backupDate> for the backup date to avoid the eight concatenations. As the following code shows, the query is much easier to understand, and you can see in advance what the replaceable parts are:

  1. DECLARE @sql nvarchar(max)
  2. SET @sql =
  3. '
  4. BACKUP DATABASE [<dbName>]
  5. TO disk = "c:\backups\<dbName>.bak"
  6. WITH description =
  7. "Backup of <dbName> at <backupDate>"
  8. '

Then, at the end of the code, you use two statements:

  1. SET @sql = REPLACE (@sql, '<dbName>',
  2. '@MyDb')
  3. SET @sql = REPLACE(@sql, '<backupDate>',
  4. CONVERT(nvarchar, getdate(), 121)

The first statement replaces <dbName> with @MyDb. The second statement formats the date, then replaces the <backupDate> tag with the formatted date.

Use double quotes. Another technique is to use double quotes instead of doubling single quotes. This is my favorite technique. Double quotes are a single character and much easier to read than doubling the single quote, so coding is less prone to errors. Then, I use the REPLACE function to switch the double quotes with real single quotes, with code such as:

                              SET @sql = REPLACE(@sql, '"', '''')                               

I usually place this REPLACE function call after all other REPLACE statements.

Replace a set of tags with other tags. When generating complex queries, I use the REPLACE function to replace a set of tags with other tags. This helps to reduce the conditional logic in multiple code generation cases. I used this technique to make the CompareRows stored procedure work either from rows generated by queries in @SrcQry and @TgtQry or from rows supplied directly from the tables specified in @SrcTab and @TgtTab. For example, at callout A in Listing 1 (below), you can see that if @SrcQry is supplied, <SrcQry> is replaced first and the next statement intended to build a query from @SrcTab would simply have no effect. The same applies to the processing of the optional @SrcInstance and @TgtInstance parameters.

Make Your Dynamic T-SQL Solutions More Readable

Without these four techniques, the CompareRows stored procedure would be an unreadable piece of junk code because it's a fairly large and complex dynamic T-SQL solution. You can find more examples of these techniques in the free YourSqlDba script at This script includes stored procedures for database maintenance and database cloning that my colleagues and I have written using these techniques. I hope that someday these techniques will be widely adopted for the sake of helping people share their dynamic T-SQL solutions.